From 7623abf457056afa2e47b2455804e9b2c7cf90a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:45:47 +0000 Subject: [PATCH 01/15] Initial plan From 9e24abc5cacf5871c6e698a94d81afe840930e54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:51:43 +0000 Subject: [PATCH 02/15] feat: add persistence module and restore/reselect modals - Add idb-keyval dependency for IndexedDB storage - Create persistence.svelte.ts with core persistence logic - Add methods to bookmarks and transcription for persistence - Update Electron IPC handlers for file operations - Add i18n strings for persistence feature in all languages - Create RestoreSessionModal and ReselectFileModal components Co-authored-by: rodrigogs <2362425+rodrigogs@users.noreply.github.com> --- electron/main.cjs | 23 + electron/preload.cjs | 3 + messages/de.json | 21 +- messages/en.json | 20 +- messages/es.json | 21 +- messages/fr.json | 21 +- messages/it.json | 21 +- messages/ja.json | 21 +- messages/nl.json | 21 +- messages/pt.json | 21 +- messages/ru.json | 21 +- messages/zh.json | 21 +- package-lock.json | 34 +- package.json | 1 + src/app.d.ts | 8 + src/lib/bookmarks.svelte.ts | 5 + src/lib/components/ReselectFileModal.svelte | 126 +++++ src/lib/components/RestoreSessionModal.svelte | 182 +++++++ src/lib/components/index.ts | 2 + src/lib/persistence.svelte.ts | 459 ++++++++++++++++++ src/lib/transcription.svelte.ts | 26 + 21 files changed, 1044 insertions(+), 34 deletions(-) create mode 100644 src/lib/components/ReselectFileModal.svelte create mode 100644 src/lib/components/RestoreSessionModal.svelte create mode 100644 src/lib/persistence.svelte.ts diff --git a/electron/main.cjs b/electron/main.cjs index fc4c8c1..a7be19f 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -227,6 +227,29 @@ ipcMain.handle('fs:fileExists', async (_event, filePath) => { return fs.existsSync(filePath); }); +// Read file from absolute path (for persistence) +ipcMain.handle('file:readFromPath', async (_event, filePath) => { + try { + const content = fs.readFileSync(filePath); + const fileName = path.basename(filePath); + return { + success: true, + buffer: content.buffer, + name: fileName, + }; + } catch (error) { + return { + success: false, + error: error.message, + }; + } +}); + +// Check if file exists at path (for persistence) +ipcMain.handle('file:exists', async (_event, filePath) => { + return fs.existsSync(filePath); +}); + ipcMain.handle('shell:openExternal', async (_event, url) => { // Validate URL before opening if (!url.startsWith('https://github.com/rodrigogs/whats-reader')) { diff --git a/electron/preload.cjs b/electron/preload.cjs index 2d42d89..4d16023 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -12,6 +12,9 @@ contextBridge.exposeInMainWorld('electronAPI', { readDir: (dirPath) => ipcRenderer.invoke('fs:readDir', dirPath), fileExists: (filePath) => ipcRenderer.invoke('fs:fileExists', filePath), + // Persistence file operations + readFileFromPath: (filePath) => ipcRenderer.invoke('file:readFromPath', filePath), + // External links openExternal: (url) => ipcRenderer.invoke('shell:openExternal', url), diff --git a/messages/de.json b/messages/de.json index fec2c18..057e7ed 100644 --- a/messages/de.json +++ b/messages/de.json @@ -241,5 +241,22 @@ "media_gallery_type_other": "Andere", "media_gallery_clear_filter": "Filter löschen", "media_gallery_participant_search_placeholder": "Suche nach Name oder Nummer...", - "media_gallery_participant_no_match": "Kein Teilnehmer entspricht \"{query}\"" -} + "persistence_remember_conversation": "Remember Conversation", + "persistence_conversation_saved": "Conversation will be remembered", + "persistence_conversation_removed": "Conversation removed from saved sessions", + "persistence_restore_title": "Restore Saved Conversations", + "persistence_restore_description": "You have saved conversations from previous sessions:", + "persistence_restore_button": "Restore Selected ({count})", + "persistence_start_fresh": "Start Fresh", + "persistence_dont_show_again": "Don't show this again", + "persistence_last_opened": "Last opened: {date}", + "persistence_message_count": "{count} messages", + "persistence_reselect_title": "Please Re-select File", + "persistence_reselect_description": "To restore \"{chatTitle}\", please select the original file:", + "persistence_reselect_expected_file": "Expected file: {fileName}", + "persistence_skip": "Skip", + "persistence_validation_failed": "This file doesn't match the saved conversation", + "persistence_validation_confirm": "This might be the correct file, but we couldn't fully verify. Continue anyway?", + "persistence_file_not_found": "The original file was moved or deleted", + "persistence_restoring": "Restoring conversations..." +} \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index dcc7388..1a97f8a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -241,5 +241,23 @@ "media_gallery_type_other": "Other", "media_gallery_clear_filter": "Clear filter", "media_gallery_participant_search_placeholder": "Search by name or number...", - "media_gallery_participant_no_match": "No participant matches \"{query}\"" + "media_gallery_participant_no_match": "No participant matches \"{query}\"", + "persistence_remember_conversation": "Remember Conversation", + "persistence_conversation_saved": "Conversation will be remembered", + "persistence_conversation_removed": "Conversation removed from saved sessions", + "persistence_restore_title": "Restore Saved Conversations", + "persistence_restore_description": "You have saved conversations from previous sessions:", + "persistence_restore_button": "Restore Selected ({count})", + "persistence_start_fresh": "Start Fresh", + "persistence_dont_show_again": "Don't show this again", + "persistence_last_opened": "Last opened: {date}", + "persistence_message_count": "{count} messages", + "persistence_reselect_title": "Please Re-select File", + "persistence_reselect_description": "To restore \"{chatTitle}\", please select the original file:", + "persistence_reselect_expected_file": "Expected file: {fileName}", + "persistence_skip": "Skip", + "persistence_validation_failed": "This file doesn't match the saved conversation", + "persistence_validation_confirm": "This might be the correct file, but we couldn't fully verify. Continue anyway?", + "persistence_file_not_found": "The original file was moved or deleted", + "persistence_restoring": "Restoring conversations..." } diff --git a/messages/es.json b/messages/es.json index 06862b9..8c9526f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -241,5 +241,22 @@ "media_gallery_type_other": "Otro", "media_gallery_clear_filter": "Limpiar filtro", "media_gallery_participant_search_placeholder": "Buscar por nombre o número...", - "media_gallery_participant_no_match": "Ningún participante coincide con \"{query}\"" -} + "persistence_remember_conversation": "Remember Conversation", + "persistence_conversation_saved": "Conversation will be remembered", + "persistence_conversation_removed": "Conversation removed from saved sessions", + "persistence_restore_title": "Restore Saved Conversations", + "persistence_restore_description": "You have saved conversations from previous sessions:", + "persistence_restore_button": "Restore Selected ({count})", + "persistence_start_fresh": "Start Fresh", + "persistence_dont_show_again": "Don't show this again", + "persistence_last_opened": "Last opened: {date}", + "persistence_message_count": "{count} messages", + "persistence_reselect_title": "Please Re-select File", + "persistence_reselect_description": "To restore \"{chatTitle}\", please select the original file:", + "persistence_reselect_expected_file": "Expected file: {fileName}", + "persistence_skip": "Skip", + "persistence_validation_failed": "This file doesn't match the saved conversation", + "persistence_validation_confirm": "This might be the correct file, but we couldn't fully verify. Continue anyway?", + "persistence_file_not_found": "The original file was moved or deleted", + "persistence_restoring": "Restoring conversations..." +} \ No newline at end of file diff --git a/messages/fr.json b/messages/fr.json index 7810134..8571c4e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -241,5 +241,22 @@ "media_gallery_type_other": "Autre", "media_gallery_clear_filter": "Effacer le filtre", "media_gallery_participant_search_placeholder": "Recherche par nom ou numéro...", - "media_gallery_participant_no_match": "Aucun participant ne correspond à \"{query}\"" -} + "persistence_remember_conversation": "Remember Conversation", + "persistence_conversation_saved": "Conversation will be remembered", + "persistence_conversation_removed": "Conversation removed from saved sessions", + "persistence_restore_title": "Restore Saved Conversations", + "persistence_restore_description": "You have saved conversations from previous sessions:", + "persistence_restore_button": "Restore Selected ({count})", + "persistence_start_fresh": "Start Fresh", + "persistence_dont_show_again": "Don't show this again", + "persistence_last_opened": "Last opened: {date}", + "persistence_message_count": "{count} messages", + "persistence_reselect_title": "Please Re-select File", + "persistence_reselect_description": "To restore \"{chatTitle}\", please select the original file:", + "persistence_reselect_expected_file": "Expected file: {fileName}", + "persistence_skip": "Skip", + "persistence_validation_failed": "This file doesn't match the saved conversation", + "persistence_validation_confirm": "This might be the correct file, but we couldn't fully verify. Continue anyway?", + "persistence_file_not_found": "The original file was moved or deleted", + "persistence_restoring": "Restoring conversations..." +} \ No newline at end of file diff --git a/messages/it.json b/messages/it.json index c1d9749..158ee69 100644 --- a/messages/it.json +++ b/messages/it.json @@ -241,5 +241,22 @@ "media_gallery_type_other": "Altro", "media_gallery_clear_filter": "Cancella filtro", "media_gallery_participant_search_placeholder": "Cerca per nome o numero...", - "media_gallery_participant_no_match": "Nessun partecipante corrisponde a \"{query}\"" -} + "persistence_remember_conversation": "Remember Conversation", + "persistence_conversation_saved": "Conversation will be remembered", + "persistence_conversation_removed": "Conversation removed from saved sessions", + "persistence_restore_title": "Restore Saved Conversations", + "persistence_restore_description": "You have saved conversations from previous sessions:", + "persistence_restore_button": "Restore Selected ({count})", + "persistence_start_fresh": "Start Fresh", + "persistence_dont_show_again": "Don't show this again", + "persistence_last_opened": "Last opened: {date}", + "persistence_message_count": "{count} messages", + "persistence_reselect_title": "Please Re-select File", + "persistence_reselect_description": "To restore \"{chatTitle}\", please select the original file:", + "persistence_reselect_expected_file": "Expected file: {fileName}", + "persistence_skip": "Skip", + "persistence_validation_failed": "This file doesn't match the saved conversation", + "persistence_validation_confirm": "This might be the correct file, but we couldn't fully verify. Continue anyway?", + "persistence_file_not_found": "The original file was moved or deleted", + "persistence_restoring": "Restoring conversations..." +} \ No newline at end of file diff --git a/messages/ja.json b/messages/ja.json index fed388d..e853e0c 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -241,5 +241,22 @@ "media_gallery_type_other": "他の", "media_gallery_clear_filter": "フィルターをクリア", "media_gallery_participant_search_placeholder": "名前または番号で検索...", - "media_gallery_participant_no_match": "「{query}」に一致する参加者はいません" -} + "persistence_remember_conversation": "Remember Conversation", + "persistence_conversation_saved": "Conversation will be remembered", + "persistence_conversation_removed": "Conversation removed from saved sessions", + "persistence_restore_title": "Restore Saved Conversations", + "persistence_restore_description": "You have saved conversations from previous sessions:", + "persistence_restore_button": "Restore Selected ({count})", + "persistence_start_fresh": "Start Fresh", + "persistence_dont_show_again": "Don't show this again", + "persistence_last_opened": "Last opened: {date}", + "persistence_message_count": "{count} messages", + "persistence_reselect_title": "Please Re-select File", + "persistence_reselect_description": "To restore \"{chatTitle}\", please select the original file:", + "persistence_reselect_expected_file": "Expected file: {fileName}", + "persistence_skip": "Skip", + "persistence_validation_failed": "This file doesn't match the saved conversation", + "persistence_validation_confirm": "This might be the correct file, but we couldn't fully verify. Continue anyway?", + "persistence_file_not_found": "The original file was moved or deleted", + "persistence_restoring": "Restoring conversations..." +} \ No newline at end of file diff --git a/messages/nl.json b/messages/nl.json index 9556db4..0e4e1db 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -241,5 +241,22 @@ "media_gallery_type_other": "Ander", "media_gallery_clear_filter": "Filter wissen", "media_gallery_participant_search_placeholder": "Zoeken op naam of nummer...", - "media_gallery_participant_no_match": "Geen deelnemer komt overeen met \"{query}\"" -} + "persistence_remember_conversation": "Remember Conversation", + "persistence_conversation_saved": "Conversation will be remembered", + "persistence_conversation_removed": "Conversation removed from saved sessions", + "persistence_restore_title": "Restore Saved Conversations", + "persistence_restore_description": "You have saved conversations from previous sessions:", + "persistence_restore_button": "Restore Selected ({count})", + "persistence_start_fresh": "Start Fresh", + "persistence_dont_show_again": "Don't show this again", + "persistence_last_opened": "Last opened: {date}", + "persistence_message_count": "{count} messages", + "persistence_reselect_title": "Please Re-select File", + "persistence_reselect_description": "To restore \"{chatTitle}\", please select the original file:", + "persistence_reselect_expected_file": "Expected file: {fileName}", + "persistence_skip": "Skip", + "persistence_validation_failed": "This file doesn't match the saved conversation", + "persistence_validation_confirm": "This might be the correct file, but we couldn't fully verify. Continue anyway?", + "persistence_file_not_found": "The original file was moved or deleted", + "persistence_restoring": "Restoring conversations..." +} \ No newline at end of file diff --git a/messages/pt.json b/messages/pt.json index 18d91b2..d26e0c8 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -241,5 +241,22 @@ "media_gallery_type_other": "Outro", "media_gallery_clear_filter": "Limpar filtro", "media_gallery_participant_search_placeholder": "Pesquisar por nome ou número...", - "media_gallery_participant_no_match": "Nenhum participante corresponde a \"{query}\"" -} + "persistence_remember_conversation": "Remember Conversation", + "persistence_conversation_saved": "Conversation will be remembered", + "persistence_conversation_removed": "Conversation removed from saved sessions", + "persistence_restore_title": "Restore Saved Conversations", + "persistence_restore_description": "You have saved conversations from previous sessions:", + "persistence_restore_button": "Restore Selected ({count})", + "persistence_start_fresh": "Start Fresh", + "persistence_dont_show_again": "Don't show this again", + "persistence_last_opened": "Last opened: {date}", + "persistence_message_count": "{count} messages", + "persistence_reselect_title": "Please Re-select File", + "persistence_reselect_description": "To restore \"{chatTitle}\", please select the original file:", + "persistence_reselect_expected_file": "Expected file: {fileName}", + "persistence_skip": "Skip", + "persistence_validation_failed": "This file doesn't match the saved conversation", + "persistence_validation_confirm": "This might be the correct file, but we couldn't fully verify. Continue anyway?", + "persistence_file_not_found": "The original file was moved or deleted", + "persistence_restoring": "Restoring conversations..." +} \ No newline at end of file diff --git a/messages/ru.json b/messages/ru.json index 7c76d60..b323769 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -241,5 +241,22 @@ "media_gallery_type_other": "Другой", "media_gallery_clear_filter": "Очистить фильтр", "media_gallery_participant_search_placeholder": "Поиск по имени или номеру...", - "media_gallery_participant_no_match": "Ни один участник не соответствует \"{query}\"" -} + "persistence_remember_conversation": "Remember Conversation", + "persistence_conversation_saved": "Conversation will be remembered", + "persistence_conversation_removed": "Conversation removed from saved sessions", + "persistence_restore_title": "Restore Saved Conversations", + "persistence_restore_description": "You have saved conversations from previous sessions:", + "persistence_restore_button": "Restore Selected ({count})", + "persistence_start_fresh": "Start Fresh", + "persistence_dont_show_again": "Don't show this again", + "persistence_last_opened": "Last opened: {date}", + "persistence_message_count": "{count} messages", + "persistence_reselect_title": "Please Re-select File", + "persistence_reselect_description": "To restore \"{chatTitle}\", please select the original file:", + "persistence_reselect_expected_file": "Expected file: {fileName}", + "persistence_skip": "Skip", + "persistence_validation_failed": "This file doesn't match the saved conversation", + "persistence_validation_confirm": "This might be the correct file, but we couldn't fully verify. Continue anyway?", + "persistence_file_not_found": "The original file was moved or deleted", + "persistence_restoring": "Restoring conversations..." +} \ No newline at end of file diff --git a/messages/zh.json b/messages/zh.json index 76e59ce..0804ff7 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -241,5 +241,22 @@ "media_gallery_type_other": "其他", "media_gallery_clear_filter": "清除过滤器", "media_gallery_participant_search_placeholder": "按名称或编号搜索……", - "media_gallery_participant_no_match": "没有参与者匹配\"{query}\"" -} + "persistence_remember_conversation": "Remember Conversation", + "persistence_conversation_saved": "Conversation will be remembered", + "persistence_conversation_removed": "Conversation removed from saved sessions", + "persistence_restore_title": "Restore Saved Conversations", + "persistence_restore_description": "You have saved conversations from previous sessions:", + "persistence_restore_button": "Restore Selected ({count})", + "persistence_start_fresh": "Start Fresh", + "persistence_dont_show_again": "Don't show this again", + "persistence_last_opened": "Last opened: {date}", + "persistence_message_count": "{count} messages", + "persistence_reselect_title": "Please Re-select File", + "persistence_reselect_description": "To restore \"{chatTitle}\", please select the original file:", + "persistence_reselect_expected_file": "Expected file: {fileName}", + "persistence_skip": "Skip", + "persistence_validation_failed": "This file doesn't match the saved conversation", + "persistence_validation_confirm": "This might be the correct file, but we couldn't fully verify. Continue anyway?", + "persistence_file_not_found": "The original file was moved or deleted", + "persistence_restoring": "Restoring conversations..." +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4c80228..d1c8f9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@floating-ui/dom": "^1.7.4", "@huggingface/transformers": "^3.8.1", "electron-updater": "^6.6.2", + "idb-keyval": "^6.2.2", "jszip": "^3.10.1" }, "devDependencies": { @@ -709,6 +710,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -2168,7 +2170,6 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -3435,7 +3436,6 @@ "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -3475,7 +3475,6 @@ "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -3962,7 +3961,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4013,7 +4011,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5249,7 +5246,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -5824,6 +5822,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -5844,6 +5843,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -5859,6 +5859,7 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -5869,6 +5870,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -7142,6 +7144,12 @@ "node": ">=0.10.0" } }, + "node_modules/idb-keyval": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", + "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", + "license": "Apache-2.0" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -7668,7 +7676,6 @@ "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14.0.0" } @@ -8235,7 +8242,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -10779,7 +10785,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11527,6 +11532,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -11544,6 +11550,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -12039,7 +12046,6 @@ "integrity": "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -13198,7 +13204,6 @@ "integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -13345,6 +13350,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -13395,6 +13401,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -13409,6 +13416,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -13578,7 +13586,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13701,7 +13708,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13945,7 +13951,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -14039,7 +14044,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index 20155f5..7beed1e 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@floating-ui/dom": "^1.7.4", "@huggingface/transformers": "^3.8.1", "electron-updater": "^6.6.2", + "idb-keyval": "^6.2.2", "jszip": "^3.10.1" }, "overrides": { diff --git a/src/app.d.ts b/src/app.d.ts index 0d1d16f..2e20520 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -13,6 +13,13 @@ interface ReadResult { error?: string; } +interface FileReadResult { + success: boolean; + buffer?: ArrayBuffer; + name?: string; + error?: string; +} + interface DirEntry { name: string; isDirectory: boolean; @@ -31,6 +38,7 @@ interface ElectronAPI { readFile: (filePath: string) => Promise; readDir: (dirPath: string) => Promise; fileExists: (filePath: string) => Promise; + readFileFromPath: (filePath: string) => Promise; openExternal: (url: string) => Promise; platform: string; isElectron: boolean; diff --git a/src/lib/bookmarks.svelte.ts b/src/lib/bookmarks.svelte.ts index 93672fe..995be65 100644 --- a/src/lib/bookmarks.svelte.ts +++ b/src/lib/bookmarks.svelte.ts @@ -205,6 +205,11 @@ function createBookmarksState() { return this.importBookmarks(data); }, + // Get bookmarks for a specific chat (for persistence) + getBookmarksForChatAsExport(chatId: string): Bookmark[] { + return bookmarks.filter((b) => b.chatId === chatId); + }, + // Clear all bookmarks clearAll(): void { bookmarks = []; diff --git a/src/lib/components/ReselectFileModal.svelte b/src/lib/components/ReselectFileModal.svelte new file mode 100644 index 0000000..5ed4e0b --- /dev/null +++ b/src/lib/components/ReselectFileModal.svelte @@ -0,0 +1,126 @@ + + + + + +
+ +

+ {m.persistence_reselect_description({ chatTitle: chatMetadata.chatTitle })} +

+ + +
+
+ {m.persistence_reselect_expected_file({ fileName: '' })} +
+
+ + {chatMetadata.fileName} +
+
+ + +
+
+ +
+

+ Drop ZIP file here +

+

+ or +

+
+ +
+
+ + +
+ +
+
+
+
diff --git a/src/lib/components/RestoreSessionModal.svelte b/src/lib/components/RestoreSessionModal.svelte new file mode 100644 index 0000000..534a562 --- /dev/null +++ b/src/lib/components/RestoreSessionModal.svelte @@ -0,0 +1,182 @@ + + + + + +
+ +

+ {m.persistence_restore_description()} +

+ + +
+ + + +
+ + +
+ {#each persistedChats as chat (chat.id)} + + {/each} +
+ + + + + +
+ + +
+
+
+
diff --git a/src/lib/components/index.ts b/src/lib/components/index.ts index 36975aa..a858a25 100644 --- a/src/lib/components/index.ts +++ b/src/lib/components/index.ts @@ -17,6 +17,8 @@ export { default as MessageBubble } from './MessageBubble.svelte'; export { default as Modal } from './Modal.svelte'; export { default as ModalContent } from './ModalContent.svelte'; export { default as ModalHeader } from './ModalHeader.svelte'; +export { default as ReselectFileModal } from './ReselectFileModal.svelte'; +export { default as RestoreSessionModal } from './RestoreSessionModal.svelte'; export { default as SearchBar } from './SearchBar.svelte'; export { default as UpdateToast } from './UpdateToast.svelte'; export { default as VersionBadge } from './VersionBadge.svelte'; diff --git a/src/lib/persistence.svelte.ts b/src/lib/persistence.svelte.ts new file mode 100644 index 0000000..dbc7355 --- /dev/null +++ b/src/lib/persistence.svelte.ts @@ -0,0 +1,459 @@ +/** + * Persistent Conversation Storage + * Allows users to save chat sessions across app restarts without storing large ZIP files. + * + * Storage Strategy (Hybrid Approach): + * - Electron: Store absolute file path, read from disk on restore + * - Web (Chromium): Store FileSystemFileHandle in IndexedDB + * - Web (Firefox/Safari): Store metadata only, prompt to re-select file + * + * What Gets Stored (Always Small ~1MB): + * - Chat metadata (title, filename, timestamps, message count) + * - Bookmarks array + * - Transcriptions map + * - Settings (language, autoLoadMedia, perspective) + * - File reference (path/handle/reselect-required marker) + * + * The actual ZIP file content is NEVER stored in the browser. + */ + +import { browser } from '$app/environment'; +import { get, set, del, keys } from 'idb-keyval'; +import type { Bookmark } from './bookmarks.svelte'; +import type { ChatData } from './state.svelte'; + +export interface PersistedChatMetadata { + id: string; // Unique ID (crypto.randomUUID) + fileName: string; // Original filename + chatTitle: string; // Parsed chat title (for display) + messageCount: number; // For validation + firstMessageTimestamp: string; // ISO string - for validation + lastMessageTimestamp: string; // ISO string - for validation + firstMessageIds: string[]; // First 5 message IDs (backup validation for iOS) + savedAt: string; // ISO timestamp + updatedAt: string; // ISO timestamp (for sorting) + + // File reference (varies by platform) + fileReference: + | { type: 'electron-path'; filePath: string } + | { type: 'file-handle'; handleId: string } // Handle stored separately + | { type: 'reselect-required' }; + + // Persisted data (always stored - small) + bookmarks: Bookmark[]; + transcriptions: Record; + settings: { + language: string; + autoLoadMedia: boolean; + perspective: string | null; + }; +} + +export interface ValidationResult { + valid: boolean; + confidence: 'high' | 'medium' | 'low'; + reasons: string[]; +} + +const PERSISTENCE_PREFIX = 'whatsapp-persisted-chat-'; +const HANDLE_PREFIX = 'whatsapp-file-handle-'; +const DONT_SHOW_KEY = 'whatsapp-dont-show-restore-modal'; + +/** + * Check if File System Access API is supported + */ +export function isFileSystemAccessSupported(): boolean { + if (!browser) return false; + return 'showOpenFilePicker' in window; +} + +/** + * Request persistent storage to prevent eviction + */ +export async function requestPersistentStorage(): Promise { + if (!browser || !navigator.storage?.persist) return false; + try { + const granted = await navigator.storage.persist(); + return granted; + } catch (e) { + console.error('Failed to request persistent storage:', e); + return false; + } +} + +/** + * Save a persisted chat + */ +export async function savePersistedChat( + chat: ChatData, + file: File | null, + bookmarks: Bookmark[], + transcriptions: Record, + settings: { + language: string; + autoLoadMedia: boolean; + perspective: string | null; + }, + filePath?: string, // For Electron +): Promise { + if (!browser) throw new Error('Persistence only available in browser'); + + // Generate unique ID + const id = crypto.randomUUID(); + + // Extract first 5 message IDs for validation + const firstMessageIds = chat.messages + .slice(0, 5) + .map((msg) => msg.id) + .filter(Boolean); + + // Determine file reference based on platform + let fileReference: PersistedChatMetadata['fileReference']; + + // Check if running in Electron + const isElectron = window.electronAPI?.isElectron; + + if (isElectron && filePath) { + // Electron: store absolute file path + fileReference = { type: 'electron-path', filePath }; + } else if (isFileSystemAccessSupported() && file) { + // Web (Chromium): try to get file handle + try { + // Modern browsers allow getting a handle from a File object + // We need to prompt the user to select the file again to get a handle we can store + // For now, mark as reselect-required since we can't get a handle from drag-drop + fileReference = { type: 'reselect-required' }; + } catch (e) { + fileReference = { type: 'reselect-required' }; + } + } else { + // Firefox/Safari or no file: require reselect + fileReference = { type: 'reselect-required' }; + } + + const metadata: PersistedChatMetadata = { + id, + fileName: file?.name || chat.title, + chatTitle: chat.title, + messageCount: chat.messages.length, + firstMessageTimestamp: + chat.messages[0]?.timestamp.toISOString() || new Date().toISOString(), + lastMessageTimestamp: + chat.messages[chat.messages.length - 1]?.timestamp.toISOString() || + new Date().toISOString(), + firstMessageIds, + savedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + fileReference, + bookmarks, + transcriptions, + settings, + }; + + // Save metadata + await set(`${PERSISTENCE_PREFIX}${id}`, metadata); + + // Request persistent storage + await requestPersistentStorage(); + + return id; +} + +/** + * Get all persisted chats (sorted by updatedAt) + */ +export async function getPersistedChats(): Promise { + if (!browser) return []; + + try { + const allKeys = await keys(); + const chatKeys = allKeys.filter( + (key) => typeof key === 'string' && key.startsWith(PERSISTENCE_PREFIX), + ); + + const chats: PersistedChatMetadata[] = []; + for (const key of chatKeys) { + const metadata = await get(key as string); + if (metadata) { + chats.push(metadata); + } + } + + // Sort by updatedAt (most recent first) + return chats.sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + } catch (e) { + console.error('Failed to get persisted chats:', e); + return []; + } +} + +/** + * Remove a persisted chat + */ +export async function removePersistedChat(id: string): Promise { + if (!browser) return; + + try { + await del(`${PERSISTENCE_PREFIX}${id}`); + // Also remove file handle if exists + await del(`${HANDLE_PREFIX}${id}`); + } catch (e) { + console.error('Failed to remove persisted chat:', e); + } +} + +/** + * Update a persisted chat (for updating bookmarks/transcriptions) + */ +export async function updatePersistedChat( + id: string, + updates: Partial, +): Promise { + if (!browser) return; + + try { + const metadata = await get( + `${PERSISTENCE_PREFIX}${id}`, + ); + if (!metadata) throw new Error('Chat not found'); + + const updated: PersistedChatMetadata = { + ...metadata, + ...updates, + updatedAt: new Date().toISOString(), + }; + + await set(`${PERSISTENCE_PREFIX}${id}`, updated); + } catch (e) { + console.error('Failed to update persisted chat:', e); + throw e; + } +} + +/** + * Get persisted chat by ID + */ +export async function getPersistedChat( + id: string, +): Promise { + if (!browser) return null; + + try { + return (await get(`${PERSISTENCE_PREFIX}${id}`)) || null; + } catch (e) { + console.error('Failed to get persisted chat:', e); + return null; + } +} + +/** + * Validate a restored file against saved metadata + */ +export function validateRestoredFile( + parsed: ChatData, + saved: PersistedChatMetadata, +): ValidationResult { + const reasons: string[] = []; + let matchCount = 0; + + // Check message count + const messageCountMatch = parsed.messages.length === saved.messageCount; + if (messageCountMatch) { + matchCount++; + } else { + reasons.push( + `Message count mismatch: expected ${saved.messageCount}, got ${parsed.messages.length}`, + ); + } + + // Check first message timestamp + const firstMessageMatch = + parsed.messages[0]?.timestamp.toISOString() === + saved.firstMessageTimestamp; + if (firstMessageMatch) { + matchCount++; + } else { + reasons.push('First message timestamp mismatch'); + } + + // Check last message timestamp + const lastMessageMatch = + parsed.messages[parsed.messages.length - 1]?.timestamp.toISOString() === + saved.lastMessageTimestamp; + if (lastMessageMatch) { + matchCount++; + } else { + reasons.push('Last message timestamp mismatch'); + } + + // Check first message IDs + const parsedFirstIds = parsed.messages + .slice(0, 5) + .map((msg) => msg.id) + .filter(Boolean); + const firstIdsMatch = + parsedFirstIds.length === saved.firstMessageIds.length && + parsedFirstIds.every((id, idx) => id === saved.firstMessageIds[idx]); + if (firstIdsMatch) { + matchCount++; + } else { + reasons.push('First message IDs mismatch'); + } + + // Determine confidence level + let confidence: 'high' | 'medium' | 'low'; + if (messageCountMatch && firstMessageMatch && lastMessageMatch) { + confidence = 'high'; + } else if (matchCount >= 2) { + confidence = 'medium'; + } else { + confidence = 'low'; + } + + return { + valid: matchCount >= 2, // At least 2 signals must match + confidence, + reasons, + }; +} + +/** + * Restore a chat from file (Electron path or reselected file) + */ +export async function restoreChat( + persistedChat: PersistedChatMetadata, + file?: File, +): Promise<{ + success: boolean; + data?: { + buffer: ArrayBuffer; + name: string; + metadata: PersistedChatMetadata; + }; + error?: string; + needsReselect?: boolean; +}> { + if (!browser) + return { success: false, error: 'Restoration only available in browser' }; + + const { fileReference } = persistedChat; + + try { + // Electron path + if (fileReference.type === 'electron-path') { + const isElectron = window.electronAPI?.isElectron; + if (!isElectron) { + return { success: false, needsReselect: true }; + } + + // Check if file exists + const exists = await window.electronAPI.fileExists( + fileReference.filePath, + ); + if (!exists) { + return { + success: false, + error: 'File not found at saved path', + needsReselect: true, + }; + } + + // Read file from path + const result = await window.electronAPI.readFileFromPath( + fileReference.filePath, + ); + if (!result.success || !result.buffer) { + return { + success: false, + error: result.error || 'Failed to read file', + needsReselect: true, + }; + } + + return { + success: true, + data: { + buffer: result.buffer, + name: result.name || persistedChat.fileName, + metadata: persistedChat, + }, + }; + } + + // File handle (not implemented yet - requires user permission flow) + if (fileReference.type === 'file-handle') { + // For now, treat as reselect-required + return { success: false, needsReselect: true }; + } + + // Reselect required + if (fileReference.type === 'reselect-required') { + if (!file) { + return { success: false, needsReselect: true }; + } + + // User provided file - use it + const buffer = await file.arrayBuffer(); + return { + success: true, + data: { + buffer, + name: file.name, + metadata: persistedChat, + }, + }; + } + + return { success: false, error: 'Unknown file reference type' }; + } catch (e) { + console.error('Failed to restore chat:', e); + return { + success: false, + error: e instanceof Error ? e.message : 'Unknown error', + }; + } +} + +/** + * Get "don't show again" preference + */ +export async function getDontShowRestoreModal(): Promise { + if (!browser) return false; + try { + return (await get(DONT_SHOW_KEY)) || false; + } catch (e) { + return false; + } +} + +/** + * Set "don't show again" preference + */ +export async function setDontShowRestoreModal(value: boolean): Promise { + if (!browser) return; + try { + await set(DONT_SHOW_KEY, value); + } catch (e) { + console.error('Failed to set dont show preference:', e); + } +} + +/** + * Find persisted chat by chat title + */ +export async function findPersistedChatByTitle( + chatTitle: string, +): Promise { + if (!browser) return null; + + try { + const chats = await getPersistedChats(); + return chats.find((chat) => chat.chatTitle === chatTitle) || null; + } catch (e) { + console.error('Failed to find persisted chat:', e); + return null; + } +} diff --git a/src/lib/transcription.svelte.ts b/src/lib/transcription.svelte.ts index b7a3519..3953b8e 100644 --- a/src/lib/transcription.svelte.ts +++ b/src/lib/transcription.svelte.ts @@ -161,6 +161,32 @@ export function getAllTranscriptions(): Record { return result; } +/** + * Get transcriptions for a specific chat (for persistence) + */ +export function getTranscriptionsForChat( + chatId: string, +): Record { + // For now, return all transcriptions + // In the future, we could filter by chatId if we track it per transcription + return getAllTranscriptions(); +} + +/** + * Set transcriptions for a chat (for restoration) + */ +export function setTranscriptionsForChat( + chatId: string, + transcriptions: Record, +): void { + // Merge with existing transcriptions + for (const [messageId, text] of Object.entries(transcriptions)) { + transcriptionStore.set(messageId, text); + } + // Trigger reactivity + transcriptionStore = new Map(transcriptionStore); +} + /** * Pre-load the Whisper model */ From b2cc907b1c1866f8f7b0f84b480199e699da092e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:57:45 +0000 Subject: [PATCH 03/15] feat: implement persistence restoration flow and UI integration - Update ChatList with "Remember Conversation" toggle - Implement restoration flow in +page.svelte - Add RestoreSessionModal and ReselectFileModal to UI - Handle file references for both Electron and web - Restore bookmarks, transcriptions, and settings on load - Fix linting and type errors Co-authored-by: rodrigogs <2362425+rodrigogs@users.noreply.github.com> --- electron/preload.cjs | 3 +- src/lib/components/ChatList.svelte | 31 ++ src/lib/components/ReselectFileModal.svelte | 10 +- src/lib/components/RestoreSessionModal.svelte | 16 +- src/lib/persistence.svelte.ts | 15 +- src/lib/transcription.svelte.ts | 4 +- src/routes/+page.svelte | 395 +++++++++++++++++- 7 files changed, 450 insertions(+), 24 deletions(-) diff --git a/electron/preload.cjs b/electron/preload.cjs index 4d16023..8aa55e7 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -13,7 +13,8 @@ contextBridge.exposeInMainWorld('electronAPI', { fileExists: (filePath) => ipcRenderer.invoke('fs:fileExists', filePath), // Persistence file operations - readFileFromPath: (filePath) => ipcRenderer.invoke('file:readFromPath', filePath), + readFileFromPath: (filePath) => + ipcRenderer.invoke('file:readFromPath', filePath), // External links openExternal: (url) => ipcRenderer.invoke('shell:openExternal', url), diff --git a/src/lib/components/ChatList.svelte b/src/lib/components/ChatList.svelte index d5da677..312bf29 100644 --- a/src/lib/components/ChatList.svelte +++ b/src/lib/components/ChatList.svelte @@ -25,6 +25,8 @@ interface Props { autoLoadMediaByChat?: Map; onAutoLoadMediaChange?: (chatTitle: string, enabled: boolean) => void; loadingChats?: LoadingChat[]; + rememberedChats?: Set; + onToggleRemember?: (chatTitle: string, enabled: boolean) => void; } let { @@ -37,6 +39,8 @@ let { autoLoadMediaByChat = new Map(), onAutoLoadMediaChange, loadingChats = [], + rememberedChats = new Set(), + onToggleRemember, }: Props = $props(); const stageLabels = { @@ -123,6 +127,19 @@ function handleAutoLoadToggle() { closeContextMenu(); } +function isRemembered(chatTitle: string): boolean { + return rememberedChats.has(chatTitle); +} + +function handleToggleRemember() { + if (contextMenuIndex !== null && onToggleRemember) { + const chat = chats[contextMenuIndex]; + const currentRemembered = isRemembered(chat.title); + onToggleRemember(chat.title, !currentRemembered); + } + closeContextMenu(); +} + function formatDate(date: Date | null): string { if (!date) return ''; const locale = getLocale(); @@ -356,6 +373,20 @@ function getLastMessage(chat: ChatData): string {
+ + + + + {m.persistence_remember_conversation()} + + {#if isRemembered(chats[contextMenuIndex]?.title || '')} + + {/if} + + + +
+ - - + +
@@ -71,13 +71,15 @@ function handleFileInput(e: Event) { {m.persistence_reselect_expected_file({ fileName: '' })}
- + {chatMetadata.fileName}
- +

Drop ZIP file here diff --git a/src/lib/components/RestoreSessionModal.svelte b/src/lib/components/RestoreSessionModal.svelte index 534a562..31632c4 100644 --- a/src/lib/components/RestoreSessionModal.svelte +++ b/src/lib/components/RestoreSessionModal.svelte @@ -41,11 +41,11 @@ function deselectAll() { async function handleRestore() { if (selectedChatIds.size === 0) return; - + if (dontShowAgain) { await setDontShowRestoreModal(true); } - + onRestore(Array.from(selectedChatIds)); } @@ -53,7 +53,7 @@ async function handleStartFresh() { if (dontShowAgain) { await setDontShowRestoreModal(true); } - + onStartFresh(); } @@ -63,7 +63,7 @@ function formatDate(dateStr: string): string { const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - + if (diffDays === 0) { return m.time_today(); } else if (diffDays === 1) { @@ -74,14 +74,14 @@ function formatDate(dateStr: string): string { return date.toLocaleDateString(locale, { year: 'numeric', month: 'short', - day: 'numeric' + day: 'numeric', }); } } - - + +

@@ -126,7 +126,7 @@ function formatDate(dateStr: string): string { : 'border-neutral-300 dark:border-neutral-600'}" > {#if selectedChatIds.has(chat.id)} - + {/if}
diff --git a/src/lib/persistence.svelte.ts b/src/lib/persistence.svelte.ts index dbc7355..ddaec39 100644 --- a/src/lib/persistence.svelte.ts +++ b/src/lib/persistence.svelte.ts @@ -17,8 +17,8 @@ * The actual ZIP file content is NEVER stored in the browser. */ +import { del, get, keys, set } from 'idb-keyval'; import { browser } from '$app/environment'; -import { get, set, del, keys } from 'idb-keyval'; import type { Bookmark } from './bookmarks.svelte'; import type { ChatData } from './state.svelte'; @@ -123,7 +123,7 @@ export async function savePersistedChat( // We need to prompt the user to select the file again to get a handle we can store // For now, mark as reselect-required since we can't get a handle from drag-drop fileReference = { type: 'reselect-required' }; - } catch (e) { + } catch (_e) { fileReference = { type: 'reselect-required' }; } } else { @@ -242,7 +242,9 @@ export async function getPersistedChat( if (!browser) return null; try { - return (await get(`${PERSISTENCE_PREFIX}${id}`)) || null; + return ( + (await get(`${PERSISTENCE_PREFIX}${id}`)) || null + ); } catch (e) { console.error('Failed to get persisted chat:', e); return null; @@ -271,8 +273,7 @@ export function validateRestoredFile( // Check first message timestamp const firstMessageMatch = - parsed.messages[0]?.timestamp.toISOString() === - saved.firstMessageTimestamp; + parsed.messages[0]?.timestamp.toISOString() === saved.firstMessageTimestamp; if (firstMessageMatch) { matchCount++; } else { @@ -345,7 +346,7 @@ export async function restoreChat( // Electron path if (fileReference.type === 'electron-path') { const isElectron = window.electronAPI?.isElectron; - if (!isElectron) { + if (!isElectron || !window.electronAPI) { return { success: false, needsReselect: true }; } @@ -424,7 +425,7 @@ export async function getDontShowRestoreModal(): Promise { if (!browser) return false; try { return (await get(DONT_SHOW_KEY)) || false; - } catch (e) { + } catch (_e) { return false; } } diff --git a/src/lib/transcription.svelte.ts b/src/lib/transcription.svelte.ts index 3953b8e..f2c2282 100644 --- a/src/lib/transcription.svelte.ts +++ b/src/lib/transcription.svelte.ts @@ -165,7 +165,7 @@ export function getAllTranscriptions(): Record { * Get transcriptions for a specific chat (for persistence) */ export function getTranscriptionsForChat( - chatId: string, + _chatId: string, ): Record { // For now, return all transcriptions // In the future, we could filter by chatId if we track it per transcription @@ -176,7 +176,7 @@ export function getTranscriptionsForChat( * Set transcriptions for a chat (for restoration) */ export function setTranscriptionsForChat( - chatId: string, + _chatId: string, transcriptions: Record, ): void { // Merge with existing transcriptions diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index acd4493..0c62014 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -4,6 +4,7 @@ import { browser } from '$app/environment'; import { floating } from '$lib/actions/floating'; import favicon from '$lib/assets/favicon.svg'; import { getAutoUpdaterState, initAutoUpdater } from '$lib/auto-updater.svelte'; +import { bookmarksState } from '$lib/bookmarks.svelte'; import { ChatList, ChatStats, @@ -28,6 +29,8 @@ import MediaGallery from '$lib/components/MediaGallery.svelte'; import Modal from '$lib/components/Modal.svelte'; import ModalContent from '$lib/components/ModalContent.svelte'; import ModalHeader from '$lib/components/ModalHeader.svelte'; +import ReselectFileModal from '$lib/components/ReselectFileModal.svelte'; +import RestoreSessionModal from '$lib/components/RestoreSessionModal.svelte'; import { isElectronMac as checkIsElectronMac, isElectronApp, @@ -35,8 +38,22 @@ import { } from '$lib/helpers/responsive'; import * as m from '$lib/paraglide/messages'; import { parseZipFile, readFileAsArrayBuffer } from '$lib/parser'; +import { + findPersistedChatByTitle, + getDontShowRestoreModal, + getPersistedChats, + type PersistedChatMetadata, + removePersistedChat, + restoreChat, + savePersistedChat, + updatePersistedChat, + validateRestoredFile, +} from '$lib/persistence.svelte'; import { appState, type ChatData } from '$lib/state.svelte'; -import { setTranscriptionLanguage } from '$lib/transcription.svelte'; +import { + setTranscriptionLanguage, + setTranscriptionsForChat, +} from '$lib/transcription.svelte'; // Detect if running in Electron const isElectron = isElectronApp(); @@ -140,6 +157,19 @@ let languageByChat = $state>(new Map()); // Store auto-load media preference per chat (chatTitle -> enabled) let autoLoadMediaByChat = $state>(new Map()); +// Persistence state +let rememberedChats = $state>(new Set()); // Track which chats are remembered +let showRestoreSessionModal = $state(false); +let showReselectFileModal = $state(false); +let reselectChatMetadata = $state(null); +let persistedChatsToRestore = $state([]); +let isRestoring = $state(false); + +// Track file references for persistence (chatTitle -> {file, filePath}) +let chatFileReferences = $state< + Map +>(new Map()); + // Get auto-load media setting for the current chat const autoLoadMediaForCurrentChat = $derived.by(() => { if (!appState.selectedChat) return false; @@ -215,6 +245,16 @@ async function handleFilesSelected(files: FileList) { loadingChats = loadingChats.filter((lc) => lc.id !== loadingId); appState.addChat(chatData); + // Store file reference for persistence (for Electron) + if (isElectron && window.electronAPI?.openFile) { + // In Electron, we can get the file path from the openFile dialog + // But for drag-drop, we don't have the path, so store the file object + chatFileReferences.set(chatData.title, { file }); + } else { + // In web, store the file object + chatFileReferences.set(chatData.title, { file }); + } + // Start background indexing for bookmark navigation and flat items const indexWorker = new Worker( new URL('$lib/workers/index-worker.ts', import.meta.url), @@ -421,6 +461,336 @@ const currentUser = $derived.by(() => { // Otherwise, no perspective (all messages on left) return undefined; }); + +// Check for persisted chats on app load +$effect(() => { + if (!browser) return; + + (async () => { + try { + const persisted = await getPersistedChats(); + if (persisted.length === 0) return; + + // Check if user wants to skip the modal + const dontShow = await getDontShowRestoreModal(); + if (dontShow) return; + + // Show restore modal + persistedChatsToRestore = persisted; + showRestoreSessionModal = true; + } catch (e) { + console.error('Failed to check for persisted chats:', e); + } + })(); +}); + +// Handle restoring selected chats +async function handleRestoreChats(chatIds: string[]) { + showRestoreSessionModal = false; + isRestoring = true; + + for (const chatId of chatIds) { + const persistedChat = persistedChatsToRestore.find((c) => c.id === chatId); + if (!persistedChat) continue; + + try { + const result = await restoreChat(persistedChat); + + if (result.needsReselect) { + // Show reselect modal + reselectChatMetadata = persistedChat; + showReselectFileModal = true; + // Wait for user to provide file (handled by handleReselectFile) + continue; + } + + if (!result.success || !result.data) { + console.error( + `Failed to restore chat ${persistedChat.chatTitle}:`, + result.error, + ); + continue; + } + + // Parse and load the chat + await loadChatFromBuffer( + result.data.buffer, + result.data.name, + persistedChat, + result.data.metadata.fileReference.type === 'electron-path' + ? ( + result.data.metadata.fileReference as { + type: 'electron-path'; + filePath: string; + } + ).filePath + : undefined, + ); + + // Mark as remembered + rememberedChats.add(persistedChat.chatTitle); + rememberedChats = new Set(rememberedChats); + } catch (e) { + console.error(`Error restoring chat ${persistedChat.chatTitle}:`, e); + } + } + + isRestoring = false; +} + +// Handle reselect file for a persisted chat +async function handleReselectFile(file: File) { + if (!reselectChatMetadata) return; + + showReselectFileModal = false; + + try { + // Read file + const buffer = await file.arrayBuffer(); + + // Parse and load the chat + await loadChatFromBuffer(buffer, file.name, reselectChatMetadata); + + // Mark as remembered + rememberedChats.add(reselectChatMetadata.chatTitle); + rememberedChats = new Set(rememberedChats); + } catch (e) { + console.error('Error loading reselected file:', e); + appState.setError(e instanceof Error ? e.message : 'Failed to load file'); + } finally { + reselectChatMetadata = null; + } +} + +// Skip reselect for a chat +function handleSkipReselect() { + showReselectFileModal = false; + reselectChatMetadata = null; +} + +// Handle start fresh (close restore modal without restoring) +function handleStartFresh() { + showRestoreSessionModal = false; + persistedChatsToRestore = []; +} + +// Load chat from buffer with optional restoration metadata +async function loadChatFromBuffer( + buffer: ArrayBuffer, + fileName: string, + restoredMetadata?: PersistedChatMetadata, + filePath?: string, +) { + // Create a loading placeholder + const loadingId = crypto.randomUUID(); + const displayName = fileName + .replace(/\.zip$/i, '') + .replace(/^WhatsApp Chat (with |com )/i, ''); + + loadingChats = [ + ...loadingChats, + { + id: loadingId, + filename: displayName, + progress: 0, + stage: 'extracting', + }, + ]; + + try { + // Parse ZIP file using Web Worker + const chatData: ChatData = await parseZipFile( + buffer, + async ({ stage, progress }) => { + const STAGE_PROGRESS = { + reading: { offset: 0.0, weight: 0.1 }, + extracting: { offset: 0.1, weight: 0.5 }, + parsing: { offset: 0.6, weight: 0.4 }, + } as const; + + const { offset: stageOffset, weight: stageWeight } = + STAGE_PROGRESS[stage] ?? STAGE_PROGRESS.extracting; + + const overallProgress = + 10 + (stageOffset + (progress / 100) * stageWeight) * 90; + + loadingChats = loadingChats.map((lc) => + lc.id === loadingId + ? { ...lc, progress: overallProgress, stage } + : lc, + ); + }, + ); + + // If restoring, validate the file + if (restoredMetadata) { + const validation = validateRestoredFile(chatData, restoredMetadata); + if (!validation.valid) { + console.warn('Restored file validation failed:', validation.reasons); + // Still load it but log the issue + } + + // Restore bookmarks + if (restoredMetadata.bookmarks.length > 0) { + bookmarksState.importBookmarks({ + version: 1, + exportedAt: restoredMetadata.savedAt, + bookmarks: restoredMetadata.bookmarks, + }); + } + + // Restore transcriptions + if (Object.keys(restoredMetadata.transcriptions).length > 0) { + setTranscriptionsForChat( + chatData.title, + restoredMetadata.transcriptions, + ); + } + + // Restore settings + if (restoredMetadata.settings.language) { + languageByChat.set(chatData.title, restoredMetadata.settings.language); + languageByChat = new Map(languageByChat); + } + if (restoredMetadata.settings.autoLoadMedia !== undefined) { + autoLoadMediaByChat.set( + chatData.title, + restoredMetadata.settings.autoLoadMedia, + ); + autoLoadMediaByChat = new Map(autoLoadMediaByChat); + } + if (restoredMetadata.settings.perspective !== undefined) { + perspectiveByChat.set( + chatData.title, + restoredMetadata.settings.perspective, + ); + perspectiveByChat = new Map(perspectiveByChat); + } + } + + // Remove loading placeholder and add actual chat + loadingChats = loadingChats.filter((lc) => lc.id !== loadingId); + appState.addChat(chatData); + + // Store file reference for persistence + if (filePath) { + chatFileReferences.set(chatData.title, { file: null, filePath }); + } + + // Start background indexing for bookmark navigation and flat items + const indexWorker = new Worker( + new URL('$lib/workers/index-worker.ts', import.meta.url), + { type: 'module' }, + ); + + indexWorker.onmessage = ( + event: MessageEvent<{ + chatTitle: string; + indexEntries: [string, number][]; + flatItems: Array< + | { type: 'date'; dateKey: string } + | { type: 'message'; messageId: string } + >; + serializedMessages: Array<{ + id: string; + timestamp: string; + sender: string; + content: string; + isSystemMessage: boolean; + isMediaMessage: boolean; + mediaType?: string; + rawLine: string; + }>; + }>, + ) => { + const { chatTitle, indexEntries, flatItems, serializedMessages } = + event.data; + const messageIndex = new Map(indexEntries); + appState.updateChatMessageIndex(chatTitle, messageIndex); + appState.updateChatFlatItems(chatTitle, flatItems); + appState.updateChatSerializedMessages(chatTitle, serializedMessages); + indexWorker.terminate(); + }; + + indexWorker.onerror = (err) => { + console.error('Index worker error:', err); + indexWorker.terminate(); + }; + + // Send messages to worker for indexing + const serializedMessages = chatData.messages.map((m) => ({ + id: m.id, + timestamp: m.timestamp.toISOString(), + sender: m.sender, + content: m.content, + isSystemMessage: m.isSystemMessage, + isMediaMessage: m.isMediaMessage, + mediaType: m.mediaType, + rawLine: m.rawLine, + })); + + indexWorker.postMessage({ + messages: serializedMessages, + chatTitle: chatData.title, + }); + } catch (error) { + console.error('Error parsing file:', error); + // Remove loading placeholder on error + loadingChats = loadingChats.filter((lc) => lc.id !== loadingId); + throw error; + } +} + +// Toggle remember conversation for a chat +async function handleToggleRemember(chatTitle: string, enabled: boolean) { + if (enabled) { + // Save the conversation + const chat = appState.chats.find((c) => c.title === chatTitle); + if (!chat) return; + + try { + const fileRef = chatFileReferences.get(chatTitle); + const bookmarks = bookmarksState.getBookmarksForChatAsExport(chatTitle); + const transcriptions = {}; // Get transcriptions for this chat + const settings = { + language: languageByChat.get(chatTitle) || 'portuguese', + autoLoadMedia: autoLoadMediaByChat.get(chatTitle) || false, + perspective: perspectiveByChat.get(chatTitle) || null, + }; + + await savePersistedChat( + chat, + fileRef?.file || null, + bookmarks, + transcriptions, + settings, + fileRef?.filePath, + ); + + rememberedChats.add(chatTitle); + rememberedChats = new Set(rememberedChats); + + // Show toast (you could add a toast notification here) + } catch (e) { + console.error('Failed to save conversation:', e); + } + } else { + // Remove from persistence + try { + const persistedChat = await findPersistedChatByTitle(chatTitle); + if (persistedChat) { + await removePersistedChat(persistedChat.id); + } + + rememberedChats.delete(chatTitle); + rememberedChats = new Set(rememberedChats); + + // Show toast (you could add a toast notification here) + } catch (e) { + console.error('Failed to remove conversation:', e); + } + } +}
@@ -818,6 +1188,8 @@ const currentUser = $derived.by(() => { {autoLoadMediaByChat} onAutoLoadMediaChange={handleAutoLoadMediaChange} {loadingChats} + {rememberedChats} + onToggleRemember={handleToggleRemember} />
@@ -1024,4 +1396,23 @@ const currentUser = $derived.by(() => { {#if isElectron && autoUpdaterState.isElectron} -{/if} \ No newline at end of file +{/if} + +{#if showRestoreSessionModal} + +{/if} + + +{#if showReselectFileModal && reselectChatMetadata} + +{/if} From a2e82f43bbf35ce64508ae34eef56415ca2971ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:01:07 +0000 Subject: [PATCH 04/15] fix: address code review feedback - Fix transcriptions retrieval to use getTranscriptionsForChat - Fix ReselectFileModal to pass fileName parameter correctly - Add documentation for requestPersistentStorage failure cases - Document unused chatId parameters in transcription functions - Format code and organize imports Co-authored-by: rodrigogs <2362425+rodrigogs@users.noreply.github.com> --- src/lib/components/ReselectFileModal.svelte | 2 +- src/lib/persistence.svelte.ts | 10 ++++++++++ src/lib/transcription.svelte.ts | 11 +++++++++++ src/routes/+page.svelte | 3 ++- 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/lib/components/ReselectFileModal.svelte b/src/lib/components/ReselectFileModal.svelte index ec3cb63..2522172 100644 --- a/src/lib/components/ReselectFileModal.svelte +++ b/src/lib/components/ReselectFileModal.svelte @@ -68,7 +68,7 @@ function handleFileInput(e: Event) {
- {m.persistence_reselect_expected_file({ fileName: '' })} + {m.persistence_reselect_expected_file({ fileName: chatMetadata.fileName })}
diff --git a/src/lib/persistence.svelte.ts b/src/lib/persistence.svelte.ts index ddaec39..29902e7 100644 --- a/src/lib/persistence.svelte.ts +++ b/src/lib/persistence.svelte.ts @@ -69,6 +69,16 @@ export function isFileSystemAccessSupported(): boolean { /** * Request persistent storage to prevent eviction + * + * Note: This is a browser permission request that may fail if: + * - The browser doesn't support the API + * - The user denies permission + * - The site doesn't meet browser criteria for persistent storage + * + * If the request fails, the data may still be stored but could be evicted + * by the browser if storage space is needed. + * + * @returns Promise - true if persistent storage was granted, false otherwise */ export async function requestPersistentStorage(): Promise { if (!browser || !navigator.storage?.persist) return false; diff --git a/src/lib/transcription.svelte.ts b/src/lib/transcription.svelte.ts index f2c2282..629f72b 100644 --- a/src/lib/transcription.svelte.ts +++ b/src/lib/transcription.svelte.ts @@ -163,6 +163,11 @@ export function getAllTranscriptions(): Record { /** * Get transcriptions for a specific chat (for persistence) + * + * Note: Currently returns all transcriptions as we don't track chatId per transcription. + * In the future, we could filter by chatId if we store it with each transcription. + * + * @param _chatId - Chat ID (currently unused, reserved for future filtering) */ export function getTranscriptionsForChat( _chatId: string, @@ -174,6 +179,12 @@ export function getTranscriptionsForChat( /** * Set transcriptions for a chat (for restoration) + * + * Note: Currently merges with all transcriptions as we don't track chatId per transcription. + * In the future, we could use chatId to manage transcriptions separately per chat. + * + * @param _chatId - Chat ID (currently unused, reserved for future use) + * @param transcriptions - Map of messageId to transcription text */ export function setTranscriptionsForChat( _chatId: string, diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0c62014..c9700eb 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -51,6 +51,7 @@ import { } from '$lib/persistence.svelte'; import { appState, type ChatData } from '$lib/state.svelte'; import { + getTranscriptionsForChat, setTranscriptionLanguage, setTranscriptionsForChat, } from '$lib/transcription.svelte'; @@ -751,7 +752,7 @@ async function handleToggleRemember(chatTitle: string, enabled: boolean) { try { const fileRef = chatFileReferences.get(chatTitle); const bookmarks = bookmarksState.getBookmarksForChatAsExport(chatTitle); - const transcriptions = {}; // Get transcriptions for this chat + const transcriptions = getTranscriptionsForChat(chatTitle); const settings = { language: languageByChat.get(chatTitle) || 'portuguese', autoLoadMedia: autoLoadMediaByChat.get(chatTitle) || false, From ca97e2cf8eac426f40aead0c63c415c49806b83e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:51:04 +0000 Subject: [PATCH 05/15] refactor: improve code quality with DRY principles and user feedback - Remove duplicate file:exists IPC handler (DRY violation) - Add VALIDATION_MESSAGE_ID_COUNT constant to eliminate magic numbers - Create Toast component for user notifications - Implement toast notifications when toggling "Remember Conversation" - Add chatFileReferences cleanup in handleRemoveChat - Add isElectronPathReference type guard for better type safety - Improve error handling with user-facing error toasts Co-authored-by: rodrigogs <2362425+rodrigogs@users.noreply.github.com> --- electron/main.cjs | 5 -- src/lib/components/Toast.svelte | 104 ++++++++++++++++++++++++++++++++ src/lib/persistence.svelte.ts | 18 +++++- src/routes/+page.svelte | 39 +++++++++--- 4 files changed, 149 insertions(+), 17 deletions(-) create mode 100644 src/lib/components/Toast.svelte diff --git a/electron/main.cjs b/electron/main.cjs index a7be19f..b5cf37d 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -245,11 +245,6 @@ ipcMain.handle('file:readFromPath', async (_event, filePath) => { } }); -// Check if file exists at path (for persistence) -ipcMain.handle('file:exists', async (_event, filePath) => { - return fs.existsSync(filePath); -}); - ipcMain.handle('shell:openExternal', async (_event, url) => { // Validate URL before opening if (!url.startsWith('https://github.com/rodrigogs/whats-reader')) { diff --git a/src/lib/components/Toast.svelte b/src/lib/components/Toast.svelte new file mode 100644 index 0000000..65832f2 --- /dev/null +++ b/src/lib/components/Toast.svelte @@ -0,0 +1,104 @@ + + +{#if visible} + +{/if} diff --git a/src/lib/persistence.svelte.ts b/src/lib/persistence.svelte.ts index 29902e7..98264ee 100644 --- a/src/lib/persistence.svelte.ts +++ b/src/lib/persistence.svelte.ts @@ -59,6 +59,9 @@ const PERSISTENCE_PREFIX = 'whatsapp-persisted-chat-'; const HANDLE_PREFIX = 'whatsapp-file-handle-'; const DONT_SHOW_KEY = 'whatsapp-dont-show-restore-modal'; +// Number of message IDs to store for validation (helps with iOS exports that lack chat title) +const VALIDATION_MESSAGE_ID_COUNT = 5; + /** * Check if File System Access API is supported */ @@ -111,9 +114,9 @@ export async function savePersistedChat( // Generate unique ID const id = crypto.randomUUID(); - // Extract first 5 message IDs for validation + // Extract first message IDs for validation const firstMessageIds = chat.messages - .slice(0, 5) + .slice(0, VALIDATION_MESSAGE_ID_COUNT) .map((msg) => msg.id) .filter(Boolean); @@ -302,7 +305,7 @@ export function validateRestoredFile( // Check first message IDs const parsedFirstIds = parsed.messages - .slice(0, 5) + .slice(0, VALIDATION_MESSAGE_ID_COUNT) .map((msg) => msg.id) .filter(Boolean); const firstIdsMatch = @@ -452,6 +455,15 @@ export async function setDontShowRestoreModal(value: boolean): Promise { } } +/** + * Type guard to check if a file reference is an electron-path type + */ +export function isElectronPathReference( + ref: PersistedChatMetadata['fileReference'], +): ref is { type: 'electron-path'; filePath: string } { + return ref.type === 'electron-path'; +} + /** * Find persisted chat by chat title */ diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c9700eb..b468920 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -31,6 +31,7 @@ import ModalContent from '$lib/components/ModalContent.svelte'; import ModalHeader from '$lib/components/ModalHeader.svelte'; import ReselectFileModal from '$lib/components/ReselectFileModal.svelte'; import RestoreSessionModal from '$lib/components/RestoreSessionModal.svelte'; +import Toast from '$lib/components/Toast.svelte'; import { isElectronMac as checkIsElectronMac, isElectronApp, @@ -98,6 +99,19 @@ let showParticipants = $state(false); let participantStats = $state | null>(null); let scrollToMessageId = $state(null); +// Toast notification state +let toastMessage = $state(null); +let toastType = $state<'success' | 'error' | 'info'>('success'); + +function showToast(message: string, type: 'success' | 'error' | 'info' = 'success') { + toastMessage = message; + toastType = type; +} + +function hideToast() { + toastMessage = null; +} + // Compute participant stats when modal opens (not during render) function openParticipantsModal() { if (!appState.selectedChat) return; @@ -344,6 +358,11 @@ function handleSelectChat(index: number) { } function handleRemoveChat(index: number) { + const chat = appState.chats[index]; + if (chat) { + // Clean up file reference + chatFileReferences.delete(chat.title); + } appState.removeChat(index); } @@ -518,13 +537,8 @@ async function handleRestoreChats(chatIds: string[]) { result.data.buffer, result.data.name, persistedChat, - result.data.metadata.fileReference.type === 'electron-path' - ? ( - result.data.metadata.fileReference as { - type: 'electron-path'; - filePath: string; - } - ).filePath + isElectronPathReference(result.data.metadata.fileReference) + ? result.data.metadata.fileReference.filePath : undefined, ); @@ -771,9 +785,10 @@ async function handleToggleRemember(chatTitle: string, enabled: boolean) { rememberedChats.add(chatTitle); rememberedChats = new Set(rememberedChats); - // Show toast (you could add a toast notification here) + showToast(m.persistence_conversation_saved(), 'success'); } catch (e) { console.error('Failed to save conversation:', e); + showToast('Failed to save conversation', 'error'); } } else { // Remove from persistence @@ -786,9 +801,10 @@ async function handleToggleRemember(chatTitle: string, enabled: boolean) { rememberedChats.delete(chatTitle); rememberedChats = new Set(rememberedChats); - // Show toast (you could add a toast notification here) + showToast(m.persistence_conversation_removed(), 'success'); } catch (e) { console.error('Failed to remove conversation:', e); + showToast('Failed to remove conversation', 'error'); } } } @@ -1417,3 +1433,8 @@ onSkip={handleSkipReselect} onClose={handleSkipReselect} /> {/if} + + +{#if toastMessage} + +{/if} From ff8d54605c6ba03a91a27769af21ccb6ffbabbde Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:54:58 +0000 Subject: [PATCH 06/15] fix: apply biome formatting to showToast function - Format showToast function parameters across multiple lines per biome rules - All linting checks now pass (68 files checked, 0 errors) Co-authored-by: rodrigogs <2362425+rodrigogs@users.noreply.github.com> --- src/routes/+page.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b468920..58a27ac 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -103,7 +103,10 @@ let scrollToMessageId = $state(null); let toastMessage = $state(null); let toastType = $state<'success' | 'error' | 'info'>('success'); -function showToast(message: string, type: 'success' | 'error' | 'info' = 'success') { +function showToast( + message: string, + type: 'success' | 'error' | 'info' = 'success', +) { toastMessage = message; toastType = type; } From 4a155f1ff0dea790f3e444e4ac720b8c81fdebac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:04:21 +0000 Subject: [PATCH 07/15] feat: dramatically improve restore session modal UI - Add WhatsApp-themed design with green colors - Add avatar circles with chat initials - Improve chat card design with better spacing and hover effects - Add proper icons to all UI elements - Enhance select all/deselect all buttons with icons - Add smooth transitions and animations - Improve modal elevation with backdrop blur and enhanced shadows - Add consistent styling to ReselectFileModal - Better visual hierarchy with improved typography - Add WhatsApp color scheme throughout (teal, dark green, light green) All modals now have: - Enhanced backdrop with blur effect (60% opacity) - Improved shadow for better elevation - Smooth fade-in and zoom-in animations - Better visual separation from content below Co-authored-by: rodrigogs <2362425+rodrigogs@users.noreply.github.com> --- src/lib/components/Modal.svelte | 9 +- src/lib/components/ReselectFileModal.svelte | 88 ++++++++---- src/lib/components/RestoreSessionModal.svelte | 129 ++++++++++++------ 3 files changed, 150 insertions(+), 76 deletions(-) diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte index 17fb2c3..deb8c11 100644 --- a/src/lib/components/Modal.svelte +++ b/src/lib/components/Modal.svelte @@ -50,17 +50,18 @@ $effect(() => { {#if open} - + - +