ui : add global drag-and-drop import for conversations and settings

Dropping an exported conversation (.zip, .jsonl) or settings (.json)
anywhere in the app now routes to a global importer instead of attaching
the file to a message. A state machine in use-drop-import classifies the
file, shows a review dialog (settings diff, single-conversation preview,
or bulk picker), and applies it on confirmation. The chat screen defers
import files so they are not treated as attachments.
This commit is contained in:
Aleksander Grygier
2026-08-17 09:41:51 +02:00
parent 0f901faeda
commit 67adbb18ab
10 changed files with 686 additions and 2 deletions
@@ -0,0 +1,17 @@
<script lang="ts">
import { FileDown } from '@lucide/svelte';
</script>
<div
class="pointer-events-none fixed inset-0 z-1000000 flex items-center justify-center bg-black/50 backdrop-blur-sm"
>
<div
class="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-border bg-background p-12 shadow-lg"
>
<FileDown class="mb-4 h-12 w-12 text-muted-foreground" />
<p class="text-lg font-medium text-foreground">Import conversations or settings</p>
<p class="text-sm text-muted-foreground">Drop your files here to import</p>
</div>
</div>
@@ -0,0 +1,55 @@
<script lang="ts">
import { MessageSquarePlus } from '@lucide/svelte';
import ChatMessages from '$lib/components/app/chat/ChatMessages/ChatMessages.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
interface Props {
open: boolean;
conversationName?: string;
messages?: DatabaseMessage[];
onConfirm: () => void;
onCancel: () => void;
}
let {
conversationName = '',
messages = [],
onCancel,
onConfirm,
open = $bindable()
}: Props = $props();
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
onCancel();
}
}
</script>
<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
<AlertDialog.Content class="sm:max-w-3xl">
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2">
<MessageSquarePlus class="h-5 w-5" />
Import conversation?
</AlertDialog.Title>
<AlertDialog.Description>
Preview of
<span class="font-medium">"{conversationName || 'Untitled conversation'}"</span>. Confirm to
import it into your library.
</AlertDialog.Description>
</AlertDialog.Header>
<div class="max-h-[60vh] overflow-y-auto rounded-md border">
<ChatMessages {messages} />
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={onCancel}>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={onConfirm}>Import</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,106 @@
<script lang="ts">
import { MessageSquarePlus } from '@lucide/svelte';
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { ScrollArea } from '$lib/components/ui/scroll-area';
import { UI_DATA_ATTRS } from '$lib/constants';
interface Props {
conversations: DatabaseConversation[];
messageCountMap?: Map<string, number>;
onOpen: (conversation: DatabaseConversation) => void;
onClose: () => void;
open?: boolean;
}
let {
conversations,
messageCountMap = new Map(),
onClose,
onOpen,
open = $bindable()
}: Props = $props();
let searchQuery = $state('');
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
onClose();
}
}
let filteredConversations = $derived(
conversations.filter((conv) => {
const name = conv.name || 'Untitled conversation';
return name.toLowerCase().includes(searchQuery.toLowerCase());
})
);
</script>
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
<Dialog.Portal>
<Dialog.Overlay class="z-1000000" />
<Dialog.Content class="z-1000001 max-w-2xl">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<MessageSquarePlus class="h-5 w-5" />
Imported Conversations
</Dialog.Title>
<Dialog.Description>
{conversations.length} conversation{conversations.length === 1 ? '' : 's'} imported.
Select one to open it.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<SearchInput bind:value={searchQuery} placeholder="Search conversations..." />
<div class="overflow-hidden rounded-md border">
<ScrollArea class="h-100">
<table class="w-full">
<thead class="sticky top-0 z-10 bg-muted">
<tr class="border-b">
<th class="p-3 text-left text-sm font-medium">Conversation Name</th>
<th class="w-32 p-3 text-left text-sm font-medium">Messages</th>
</tr>
</thead>
<tbody>
{#if filteredConversations.length === 0}
<tr>
<td colspan="2" class="p-8 text-center text-sm text-muted-foreground">
No conversations found matching "{searchQuery}"
</td>
</tr>
{:else}
{#each filteredConversations as conv (conv.id)}
<tr
class="cursor-pointer border-b transition-colors hover:bg-muted/50"
{...{ [UI_DATA_ATTRS.CONVERSATION_ROW]: conv.id }}
onclick={() => onOpen(conv)}
>
<td class="p-3 text-sm">
<div class="max-w-68 truncate" title={conv.name || 'Untitled conversation'}>
{conv.name || 'Untitled conversation'}
</div>
</td>
<td class="p-3 text-sm text-muted-foreground">
{messageCountMap.get(conv.id) ?? 0}
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</ScrollArea>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
@@ -0,0 +1,84 @@
<script lang="ts">
import { ArrowRight, Settings as SettingsIcon } from '@lucide/svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
export interface SettingsDiffEntry {
key: string;
label: string;
from: SettingsConfigValue;
to: SettingsConfigValue;
}
interface Props {
open: boolean;
diff?: SettingsDiffEntry[];
onConfirm: () => void;
onCancel: () => void;
}
let { diff = [], onCancel, onConfirm, open = $bindable() }: Props = $props();
function formatValue(value: SettingsConfigValue): string {
if (value === undefined) return '(unset)';
if (typeof value === 'string') {
return value || '(empty)';
}
return String(value);
}
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
onCancel();
}
}
</script>
<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
<AlertDialog.Content class="sm:max-w-2xl">
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2">
<SettingsIcon class="h-5 w-5" />
Import settings?
</AlertDialog.Title>
<AlertDialog.Description>
Review the settings that would change before importing.
</AlertDialog.Description>
</AlertDialog.Header>
<div class="max-h-[60vh] overflow-y-auto rounded-md border">
{#if diff.length === 0}
<p class="p-4 text-sm text-muted-foreground">No settings would change.</p>
{:else}
<div class="divide-y">
{#each diff as entry (entry.key)}
<div class="flex items-center gap-3 p-3">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{entry.label}</p>
<p class="truncate text-xs text-muted-foreground">{entry.key}</p>
</div>
<div class="flex shrink-0 items-center gap-2 text-sm">
<span class="line-through text-muted-foreground">{formatValue(entry.from)}</span>
<ArrowRight class="h-4 w-4 text-muted-foreground" />
<span class="font-medium">{formatValue(entry.to)}</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={onCancel}>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={onConfirm}>Import</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -375,6 +375,65 @@ export { default as DialogModelNotAvailable } from './DialogModelNotAvailable.sv
*/
export { default as DialogConversationSelection } from './DialogConversationSelection.svelte';
/**
* **DialogSettingsImportPreview** - Review the settings diff before importing
*
* Alert dialog shown when a settings file is imported via drag-and-drop.
* Lists each setting that would change (old value -> new value) so the user
* can review before confirming the import.
*
* @example
* ```svelte
* <DialogSettingsImportPreview
* bind:open={showSettingsPreview}
* diff={settingsDiff}
* onConfirm={handleConfirm}
* onCancel={() => (showSettingsPreview = false)}
* />
* ```
*/
export { default as DialogSettingsImportPreview } from './DialogSettingsImportPreview.svelte';
/**
* **DialogImportConversationPreview** - Preview a single conversation before importing
*
* Alert dialog shown when a single conversation is imported via drag-and-drop.
* Renders the conversation's messages (via ChatMessages) so the user can review
* it before confirming the import.
*
* @example
* ```svelte
* <DialogImportConversationPreview
* bind:open={showPreview}
* conversationName={conv?.name}
* messages={conv?.messages}
* onConfirm={handleConfirm}
* onCancel={() => (showPreview = false)}
* />
* ```
*/
export { default as DialogImportConversationPreview } from './DialogImportConversationPreview.svelte';
/**
* **DialogImportConversationsResult** - Pick one imported conversation to open
*
* Dialog shown after multiple conversations are imported via drag-and-drop.
* Lists the imported conversations in a table (with search) and lets the user
* click one to open it.
*
* @example
* ```svelte
* <DialogImportConversationsResult
* bind:open={showOpenBulk}
* conversations={imported}
* messageCountMap={countMap}
* onOpen={handleOpen}
* onClose={() => (showOpenBulk = false)}
* />
* ```
*/
export { default as DialogImportConversationsResult } from './DialogImportConversationsResult.svelte';
/**
*
* MODEL INFORMATION DIALOGS
+1
View File
@@ -1,5 +1,6 @@
export * from './actions';
export * from './badges';
export { default as DropImportOverlay } from './DropImportOverlay.svelte';
export * from './chat';
export * from './content';
export * from './dialogs';
@@ -8,6 +8,7 @@
*/
import { chatStore } from '$lib/stores';
import { isImportFileByExtension } from '$lib/utils/import-file.utils';
interface UseChatScreenDragAndDropOptions {
/** Called when the user drops files and no message is being edited. */
@@ -49,6 +50,17 @@ export function useChatScreenDragAndDrop(options: UseChatScreenDragAndDropOption
const files = Array.from(event.dataTransfer.files);
// Defer import files (conversation/settings exports) to the global
// drag-and-drop import handler instead of attaching them to a message.
if (files.some(isImportFileByExtension)) {
console.log('[chat-drop] deferring import files:', files.map((f) => f.name));
return;
}
// Stop bubbling so the global import handler ignores this attachment.
event.stopPropagation();
if (chatStore.isEditing()) {
const handler = chatStore.getAddFilesHandler();
@@ -0,0 +1,280 @@
/**
* Global drag-and-drop import state machine.
*
* Tracks pointer enter/leave nesting so the overlay stays visible while the
* cursor traverses child elements, then routes dropped files to the right
* importer: settings files (JSON with a `config` key) are restored directly,
* while conversation files (JSONL/ZIP/JSON) go through the existing
* selection dialog before asking whether to open the result.
*/
import { goto } from '$app/navigation';
import { ZIP_MAGIC } from '$lib/constants';
import { SETTINGS_REGISTRY } from '$lib/constants/settings.constants';
import { ConversationTransferService, RouterService } from '$lib/services';
import { conversationsStore, settingsStore } from '$lib/stores';
import { createMessageCountMap } from '$lib/utils';
import { strFromU8 } from 'fflate';
import { toast } from 'svelte-sonner';
import type { SettingsDiffEntry } from '$lib/components/app/dialogs/DialogSettingsImportPreview.svelte';
import type { SettingsConfigType, SettingsExportType } from '$lib/types';
type FileKind = 'settings' | 'conversations';
/**
* Detects whether a dropped file holds settings or conversations.
* Settings files are JSON objects carrying a `config` key; everything else
* (ZIP archives, JSONL sessions, legacy JSON) is treated as conversations.
*/
async function classifyFile(file: File): Promise<FileKind> {
const bytes = new Uint8Array(await file.arrayBuffer());
if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) {
return 'conversations';
}
const text = strFromU8(bytes);
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === 'object' && 'config' in parsed) {
return 'settings';
}
} catch {
// Not a JSON object, so not a settings file.
}
return 'conversations';
}
export function computeSettingsDiff(
current: SettingsConfigType,
imported: SettingsConfigType
): SettingsDiffEntry[] {
const labels = new Map<string, string>();
for (const section of SETTINGS_REGISTRY) {
for (const setting of section.settings) {
labels.set(setting.key, setting.label);
}
}
const keys = new Set([...Object.keys(current), ...Object.keys(imported)]);
const diff: SettingsDiffEntry[] = [];
for (const key of keys) {
const from = current[key];
const to = imported[key];
if (from !== to) {
diff.push({ key, label: labels.get(key) ?? key, from, to });
}
}
return diff;
}
export function useDropImport() {
let dragCounter = $state(0);
let isDragOver = $state(false);
// All dialog state lives in one reactive object so it stays reactive when
// exposed through the hook and bound from the layout.
const ui = $state({
// Settings import preview dialog (review diff before applying).
showSettingsPreview: false,
settingsData: null as SettingsExportType | null,
settingsDiff: [] as SettingsDiffEntry[],
// Single conversation preview dialog (confirm before importing).
showPreview: false,
previewData: null as ExportedConversation | null,
// Bulk result dialog: pick one of the imported conversations to open.
showOpenBulk: false,
importedConversations: [] as DatabaseConversation[],
bulkMessageCountMap: new Map() as Map<string, number>,
// Selection dialog (the existing import flow) for multiple conversations.
showSelection: false,
availableConversations: [] as DatabaseConversation[],
selectionMessageCountMap: new Map() as Map<string, number>,
fullImportData: [] as ExportedConversation[]
});
function handleDragEnter(event: DragEvent) {
event.preventDefault();
dragCounter++;
if (event.dataTransfer?.types.includes('Files')) {
isDragOver = true;
}
}
function handleDragLeave(event: DragEvent) {
event.preventDefault();
dragCounter--;
if (dragCounter === 0) {
isDragOver = false;
}
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
isDragOver = false;
dragCounter = 0;
if (!event.dataTransfer?.files) return;
const files = Array.from(event.dataTransfer.files);
await processFiles(files);
}
async function processFiles(files: File[]) {
const allConversations: ExportedConversation[] = [];
for (const file of files) {
const kind = await classifyFile(file);
if (kind === 'settings') {
try {
const data = JSON.parse(await file.text());
if (data?.config) {
ui.settingsData = data as SettingsExportType;
ui.settingsDiff = computeSettingsDiff(
$state.snapshot(settingsStore.config) as SettingsConfigType,
data.config
);
ui.showSettingsPreview = true;
} else {
toast.error(`Invalid settings file: ${file.name}`);
}
} catch (err) {
console.error('Failed to import settings:', err);
toast.error(`Failed to import settings from ${file.name}`);
}
} else {
try {
const parsed = await ConversationTransferService.parseImportFile(file);
allConversations.push(...parsed);
} catch (err) {
console.error('Failed to parse file:', err);
toast.error(`Failed to parse ${file.name}`);
}
}
}
if (allConversations.length === 0) {
return;
}
if (allConversations.length === 1) {
ui.previewData = allConversations[0];
ui.showPreview = true;
} else {
ui.fullImportData = allConversations;
ui.availableConversations = allConversations.map((item) => item.conv);
ui.selectionMessageCountMap = createMessageCountMap(allConversations);
ui.showSelection = true;
}
}
async function confirmSettingsImport() {
const data = ui.settingsData;
if (!data) return;
try {
settingsStore.importSettings(data);
ui.showSettingsPreview = false;
toast.success('Settings imported successfully');
} catch (err) {
console.error('Failed to import settings:', err);
toast.error('Failed to import settings');
}
}
function cancelSettingsImport() {
ui.showSettingsPreview = false;
}
async function confirmImportSingle() {
const data = ui.previewData;
if (!data) return;
try {
await conversationsStore.importConversationsData([data]);
ui.showPreview = false;
goto(RouterService.chat(data.conv.id));
} catch (err) {
console.error('Failed to import conversation:', err);
toast.error('Failed to import conversation');
}
}
async function handleSelectionConfirm(selectedConversations: DatabaseConversation[]) {
try {
const selectedIds = new Set(selectedConversations.map((c) => c.id));
const selectedData = ($state.snapshot(ui.fullImportData) as ExportedConversation[]).filter(
(item) => selectedIds.has(item.conv.id)
);
await conversationsStore.importConversationsData(selectedData);
ui.importedConversations = selectedConversations;
ui.bulkMessageCountMap = createMessageCountMap(selectedData);
ui.showSelection = false;
ui.showOpenBulk = true;
} catch (err) {
console.error('Import failed:', err);
toast.error('Failed to import conversations');
}
}
function openConversation(conversation: DatabaseConversation) {
ui.showOpenBulk = false;
goto(RouterService.chat(conversation.id));
}
function cancelPreview() {
ui.showPreview = false;
}
function cancelBulk() {
ui.showOpenBulk = false;
}
function cancelSelection() {
ui.showSelection = false;
}
return {
dragHandlers: {
dragenter: handleDragEnter,
dragleave: handleDragLeave,
dragover: handleDragOver,
drop: handleDrop
},
get isDragOver() {
return isDragOver;
},
ui,
confirmSettingsImport,
cancelSettingsImport,
confirmImportSingle,
cancelPreview,
openConversation,
cancelBulk,
cancelSelection,
handleSelectionConfirm
};
}
@@ -0,0 +1,17 @@
/**
* Helpers for deciding whether a dropped file is an import file
* (conversation/settings export) rather than a plain attachment.
*
* The chat screen attaches arbitrary files to messages, while the global
* drag-and-drop handler imports conversations and settings. Exported
* conversations are `.jsonl` (single) or `.zip` (archive), and settings are
* `.json`, so the extension is a reliable way to route a drop to the importer.
*/
const IMPORT_FILE_EXTENSIONS = ['.zip', '.jsonl', '.json'];
export function isImportFileByExtension(file: File): boolean {
const name = file.name.toLowerCase();
return IMPORT_FILE_EXTENSIONS.some((ext) => name.endsWith(ext));
}
+55 -2
View File
@@ -4,7 +4,14 @@
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { page } from '$app/state';
import { SidebarNavigation } from '$lib/components/app';
import {
DialogConversationSelection,
DialogImportConversationPreview,
DialogImportConversationsResult,
DialogSettingsImportPreview,
DropImportOverlay,
SidebarNavigation
} from '$lib/components/app';
import { PwaMetaTags, PwaRefreshAlert } from '$lib/components/pwa';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
@@ -15,6 +22,7 @@
SETTINGS_KEYS,
TOOLTIP_DELAY_DURATION
} from '$lib/constants';
import { useDropImport } from '$lib/hooks/use-drop-import.svelte';
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import { usePwa } from '$lib/hooks/use-pwa.svelte';
import { RouterService } from '$lib/services/router.service';
@@ -100,6 +108,9 @@
}
}
// Global drag-and-drop import (works on any route)
const dropImport = useDropImport();
// Global keyboard shortcuts
const { handleKeydown } = useKeyboardShortcuts({
editActiveConversation: () => chatSidebar?.editActiveConversation?.(),
@@ -272,7 +283,13 @@
</svelte:head>
<svelte:window onkeydown={handleKeydown} bind:innerHeight bind:innerWidth />
<svelte:document onvisibilitychange={handleVisibilityChange} />
<svelte:document
onvisibilitychange={handleVisibilityChange}
ondragenter={dropImport.dragHandlers.dragenter}
ondragleave={dropImport.dragHandlers.dragleave}
ondragover={dropImport.dragHandlers.dragover}
ondrop={dropImport.dragHandlers.drop}
/>
<Tooltip.Provider delayDuration={TOOLTIP_DELAY_DURATION}>
<div class="flex flex-col md:flex-row">
@@ -294,6 +311,42 @@
<ModeWatcher />
<Toaster richColors />
{#if dropImport.isDragOver}
<DropImportOverlay />
{/if}
<DialogConversationSelection
bind:open={dropImport.ui.showSelection}
conversations={dropImport.ui.availableConversations}
messageCountMap={dropImport.ui.selectionMessageCountMap}
mode="import"
onConfirm={dropImport.handleSelectionConfirm}
onCancel={dropImport.cancelSelection}
/>
<DialogSettingsImportPreview
bind:open={dropImport.ui.showSettingsPreview}
diff={dropImport.ui.settingsDiff}
onConfirm={dropImport.confirmSettingsImport}
onCancel={dropImport.cancelSettingsImport}
/>
<DialogImportConversationPreview
bind:open={dropImport.ui.showPreview}
conversationName={dropImport.ui.previewData?.conv.name}
messages={dropImport.ui.previewData?.messages}
onConfirm={dropImport.confirmImportSingle}
onCancel={dropImport.cancelPreview}
/>
<DialogImportConversationsResult
bind:open={dropImport.ui.showOpenBulk}
conversations={dropImport.ui.importedConversations}
messageCountMap={dropImport.ui.bulkMessageCountMap}
onOpen={dropImport.openConversation}
onClose={dropImport.cancelBulk}
/>
</Tooltip.Provider>
<!-- PWA update prompt + version -->