diff --git a/src/llama-kv-cache-dsv4.cpp b/src/llama-kv-cache-dsv4.cpp index 7cb6cc18d..069da45f4 100644 --- a/src/llama-kv-cache-dsv4.cpp +++ b/src/llama-kv-cache-dsv4.cpp @@ -22,7 +22,7 @@ static constexpr uint32_t DSV4_STATE_MAGIC = 0x34565344; // DSV4 static constexpr uint32_t DSV4_STATE_VERSION = 1; static constexpr uint32_t DSV4_STATE_MODE_FULL = 0; static constexpr uint32_t DSV4_STATE_MODE_PARTIAL = 1; -static constexpr uint32_t DSV4_K_CACHE_STATE_VER = 1; +static constexpr uint32_t DSV4_K_CACHE_STATE_VER = 2; static constexpr uint32_t DSV4_COMP_STATE_VER = 1; static uint32_t dsv4_comp_size(uint32_t kv_size, uint32_t ratio) { @@ -38,6 +38,16 @@ static void dsv4_clear_tensor_stream(ggml_tensor * tensor, uint32_t stream) { ggml_backend_tensor_memset(tensor, 0, stream*stream_size, stream_size); } +static uint32_t dsv4_state_n_used_k_rows(llama_pos pos_max, uint32_t ratio, uint32_t kv_size) { + if (pos_max < 0) { + return 0; + } + + const uint64_t n_rows = ((uint64_t) pos_max + 1)/ratio; + + return (uint32_t) std::min(kv_size, n_rows); +} + static int64_t dsv4_stream_offset(uint32_t n_stream, llama_seq_id seq_id, uint32_t size) { if (n_stream <= 1) { return 0; @@ -239,6 +249,7 @@ static void dsv4_state_dst_stream_range( static void dsv4_state_write_tensor_streams( llama_io_write_i & io, ggml_tensor * tensor, + uint32_t tensor_rows, uint32_t n_rows, uint32_t s0, uint32_t ns) { @@ -247,20 +258,31 @@ static void dsv4_state_write_tensor_streams( const uint64_t rows = n_rows; const uint64_t row_size = ggml_row_size(tensor->type, tensor->ne[0]); + if (n_rows > tensor_rows) { + throw std::runtime_error("DSV4 state tensor row count exceeds storage"); + } + io.write(&type_i, sizeof(type_i)); io.write(&ne0, sizeof(ne0)); io.write(&rows, sizeof(rows)); io.write(&row_size, sizeof(row_size)); - const size_t offset = (size_t) s0*n_rows*row_size; - const size_t size = (size_t) ns*n_rows*row_size; + const size_t stream_stride = (size_t) tensor_rows*row_size; + const size_t size = (size_t) n_rows*row_size; + if (size == 0) { + return; + } - io.write_tensor(tensor, offset, size); + for (uint32_t s = 0; s < ns; ++s) { + const size_t offset = (size_t) (s0 + s)*stream_stride; + io.write_tensor(tensor, offset, size); + } } static void dsv4_state_read_tensor_streams( llama_io_read_i & io, ggml_tensor * tensor, + uint32_t tensor_rows, uint32_t n_rows, uint32_t s0, uint32_t ns) { @@ -282,18 +304,28 @@ static void dsv4_state_read_tensor_streams( if (type_i != type_i_ref || ne0 != ne0_ref || rows != rows_ref || row_size != row_size_ref) { throw std::runtime_error("DSV4 state tensor metadata mismatch"); } + if (n_rows > tensor_rows) { + throw std::runtime_error("DSV4 state tensor row count exceeds storage"); + } - const size_t offset = (size_t) s0*n_rows*row_size; - const size_t size = (size_t) ns*n_rows*row_size; + const size_t stream_stride = (size_t) tensor_rows*row_size; + const size_t size = (size_t) n_rows*row_size; + if (size == 0) { + return; + } - io.read_tensor(tensor, offset, size); + for (uint32_t s = 0; s < ns; ++s) { + const size_t offset = (size_t) (s0 + s)*stream_stride; + io.read_tensor(tensor, offset, size); + } } static void dsv4_state_write_k_cache( llama_io_write_i & io, const llama_kv_cache * kv, llama_seq_id seq_id, - llama_state_seq_flags flags) { + llama_state_seq_flags flags, + uint32_t n_rows) { GGML_UNUSED(flags); uint32_t s0; @@ -305,14 +337,18 @@ static void dsv4_state_write_k_cache( const auto layer_ids = kv->get_layer_ids(); const uint32_t n_layer = layer_ids.size(); + if (n_rows > kv_size) { + throw std::runtime_error("DSV4 K-cache state row count exceeds cache size"); + } + io.write(&version, sizeof(version)); - io.write(&kv_size, sizeof(kv_size)); + io.write(&n_rows, sizeof(n_rows)); io.write(&ns, sizeof(ns)); io.write(&n_layer, sizeof(n_layer)); for (uint32_t il : layer_ids) { io.write(&il, sizeof(il)); - dsv4_state_write_tensor_streams(io, kv->get_k_storage(il), kv_size, s0, ns); + dsv4_state_write_tensor_streams(io, kv->get_k_storage(il), kv_size, n_rows, s0, ns); } } @@ -324,19 +360,26 @@ static void dsv4_state_read_k_cache( GGML_UNUSED(flags); uint32_t version; - uint32_t kv_size_ref; + uint32_t n_rows_ref; uint32_t ns; uint32_t n_layer_ref; io.read(&version, sizeof(version)); - io.read(&kv_size_ref, sizeof(kv_size_ref)); + io.read(&n_rows_ref, sizeof(n_rows_ref)); io.read(&ns, sizeof(ns)); io.read(&n_layer_ref, sizeof(n_layer_ref)); - if (version != DSV4_K_CACHE_STATE_VER) { + if (version != 1 && version != DSV4_K_CACHE_STATE_VER) { throw std::runtime_error("DSV4 K-cache state version mismatch"); } - if (kv_size_ref != kv->get_size()) { + + const uint32_t kv_size = kv->get_size(); + if (version == 1 && n_rows_ref != kv_size) { + LLAMA_LOG_INFO("kv size ref %d kv %d\n", n_rows_ref, kv_size); + throw std::runtime_error("DSV4 K-cache state size mismatch"); + } + if (n_rows_ref > kv_size) { + LLAMA_LOG_INFO("kv rows ref %d kv %d\n", n_rows_ref, kv_size); throw std::runtime_error("DSV4 K-cache state size mismatch"); } @@ -355,7 +398,7 @@ static void dsv4_state_read_k_cache( throw std::runtime_error("DSV4 K-cache layer id mismatch"); } - dsv4_state_read_tensor_streams(io, kv->get_k_storage(il), kv->get_size(), s0, ns); + dsv4_state_read_tensor_streams(io, kv->get_k_storage(il), kv_size, n_rows_ref, s0, ns); } } @@ -882,8 +925,8 @@ void llama_dsv4_comp_state::state_write(llama_io_write_i & io, llama_seq_id seq_ for (const auto & layer : layers) { io.write(&layer.il, sizeof(layer.il)); - dsv4_state_write_tensor_streams(io, layer.kv, state_size, s0, ns); - dsv4_state_write_tensor_streams(io, layer.score, state_size, s0, ns); + dsv4_state_write_tensor_streams(io, layer.kv, state_size, state_size, s0, ns); + dsv4_state_write_tensor_streams(io, layer.score, state_size, state_size, s0, ns); } } @@ -924,8 +967,8 @@ void llama_dsv4_comp_state::state_read(llama_io_read_i & io, llama_seq_id seq_id throw std::runtime_error("DSV4 compressor state layer id mismatch"); } - dsv4_state_read_tensor_streams(io, layer.kv, state_size, s0, ns); - dsv4_state_read_tensor_streams(io, layer.score, state_size, s0, ns); + dsv4_state_read_tensor_streams(io, layer.kv, state_size, state_size, s0, ns); + dsv4_state_read_tensor_streams(io, layer.score, state_size, state_size, s0, ns); } } @@ -1328,9 +1371,19 @@ void llama_kv_cache_dsv4::state_write(llama_io_write_i & io, llama_seq_id seq_id kv_raw->state_write(io, seq_id, flags); if (!partial_only) { - dsv4_state_write_k_cache(io, kv_csa.get(), seq_id, flags); - dsv4_state_write_k_cache(io, kv_hca.get(), seq_id, flags); - dsv4_state_write_k_cache(io, kv_lid.get(), seq_id, flags); + const llama_pos pos_max = seq_id >= 0 ? kv_raw->seq_pos_max(seq_id) : -1; + + //FIXME : note that we conflate token positions with rows, which is not true for multi-modal case. + const uint32_t n_rows_csa = seq_id >= 0 ? + dsv4_state_n_used_k_rows(pos_max, DSV4_CSA_RATIO, kv_csa->get_size()) : kv_csa->get_size(); + const uint32_t n_rows_hca = seq_id >= 0 ? + dsv4_state_n_used_k_rows(pos_max, DSV4_HCA_RATIO, kv_hca->get_size()) : kv_hca->get_size(); + const uint32_t n_rows_lid = seq_id >= 0 ? + dsv4_state_n_used_k_rows(pos_max, DSV4_CSA_RATIO, kv_lid->get_size()) : kv_lid->get_size(); + + dsv4_state_write_k_cache(io, kv_csa.get(), seq_id, flags, n_rows_csa); + dsv4_state_write_k_cache(io, kv_hca.get(), seq_id, flags, n_rows_hca); + dsv4_state_write_k_cache(io, kv_lid.get(), seq_id, flags, n_rows_lid); } csa_state->state_write(io, seq_id, flags); @@ -1366,6 +1419,10 @@ void llama_kv_cache_dsv4::state_read(llama_io_read_i & io, llama_seq_id seq_id, kv_raw->state_read(io, seq_id, flags); if (!partial_only) { + kv_csa->clear(true); + kv_hca->clear(true); + kv_lid->clear(true); + dsv4_state_read_k_cache(io, kv_csa.get(), seq_id, flags); dsv4_state_read_k_cache(io, kv_hca.get(), seq_id, flags); dsv4_state_read_k_cache(io, kv_lid.get(), seq_id, flags); diff --git a/src/llama-quant.cpp b/src/llama-quant.cpp index 60caccadd..192490cc3 100644 --- a/src/llama-quant.cpp +++ b/src/llama-quant.cpp @@ -306,6 +306,9 @@ static bool tensor_allows_quantization(const llama_model_quantize_params * param // NOTE: can't use LLM_TN here because the layer number is not known quantize &= name.find("ffn_gate_inp.weight") == std::string::npos; + // do not quantize the i32 token-id -> expert-id routing table (DeepSeek-V4) + quantize &= name.find("ffn_gate_tid2eid.weight") == std::string::npos; + // these are very small (e.g. 4x4) quantize &= name.find("altup") == std::string::npos; quantize &= name.find("laurel") == std::string::npos; diff --git a/src/models/dflash.cpp b/src/models/dflash.cpp index a7b4f4435..427eed459 100644 --- a/src/models/dflash.cpp +++ b/src/models/dflash.cpp @@ -1,5 +1,6 @@ #include "models.h" +#include "llama-impl.h" #include "llama-kv-cache.h" #include "llama-kv-cache-iswa.h" @@ -164,9 +165,25 @@ llama_model_dflash::graph::graph(const llama_model & model, const llm_gra const auto * kv = is_swa ? inp_attn_iswa->mctx->get_swa() : inp_attn_iswa->mctx->get_base(); ggml_tensor * k_idxs = is_swa ? inp_attn_iswa->get_k_idxs_swa() : inp_attn_iswa->get_k_idxs(); ggml_tensor * v_idxs = is_swa ? inp_attn_iswa->get_v_idxs_swa() : inp_attn_iswa->get_v_idxs(); + // rotate K/V into the cache's rotated space + ggml_tensor * k_rot = is_swa ? inp_attn_iswa->self_k_rot_swa : inp_attn_iswa->self_k_rot; + ggml_tensor * v_rot = is_swa ? inp_attn_iswa->self_v_rot_swa : inp_attn_iswa->self_v_rot; + if (k_rot) { + Kcur = llama_mul_mat_hadamard(ctx0, Kcur, k_rot); + } + if (v_rot) { + Vcur = llama_mul_mat_hadamard(ctx0, Vcur, v_rot); + } ggml_build_forward_expand(gf, kv->cpy_k(ctx0, Kcur, k_idxs, il)); ggml_build_forward_expand(gf, kv->cpy_v(ctx0, Vcur, v_idxs, il)); } else { + // rotate K/V into the cache's rotated space + if (inp_attn->self_k_rot) { + Kcur = llama_mul_mat_hadamard(ctx0, Kcur, inp_attn->self_k_rot); + } + if (inp_attn->self_v_rot) { + Vcur = llama_mul_mat_hadamard(ctx0, Vcur, inp_attn->self_v_rot); + } ggml_build_forward_expand(gf, inp_attn->mctx->cpy_k(ctx0, Kcur, inp_attn->get_k_idxs(), il)); ggml_build_forward_expand(gf, inp_attn->mctx->cpy_v(ctx0, Vcur, inp_attn->get_v_idxs(), il)); } diff --git a/tools/ui/package-lock.json b/tools/ui/package-lock.json index 7216de682..2fe44f4c3 100644 --- a/tools/ui/package-lock.json +++ b/tools/ui/package-lock.json @@ -12,7 +12,7 @@ "@eslint/compat": "1.4.1", "@eslint/js": "9.39.2", "@internationalized/date": "3.12.2", - "@lucide/svelte": "0.515.0", + "@lucide/svelte": "1.25.0", "@modelcontextprotocol/sdk": "1.26.0", "@playwright/test": "1.56.1", "@storybook/addon-a11y": "10.2.4", @@ -3065,9 +3065,9 @@ } }, "node_modules/@lucide/svelte": { - "version": "0.515.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.515.0.tgz", - "integrity": "sha512-CEAyqcZmNBfYzVgaRmK2RFJP5tnbXxekRyDk0XX/eZQRfsJmkDvmQwXNX8C869BgNeryzmrRyjHhUL6g9ZOHNA==", + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz", + "integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==", "dev": true, "license": "ISC", "peerDependencies": { diff --git a/tools/ui/package.json b/tools/ui/package.json index 8b3516a02..4ea2bf703 100644 --- a/tools/ui/package.json +++ b/tools/ui/package.json @@ -31,7 +31,7 @@ "@eslint/compat": "1.4.1", "@eslint/js": "9.39.2", "@internationalized/date": "3.12.2", - "@lucide/svelte": "0.515.0", + "@lucide/svelte": "1.25.0", "@modelcontextprotocol/sdk": "1.26.0", "@playwright/test": "1.56.1", "@storybook/addon-a11y": "10.2.4", diff --git a/tools/ui/src/lib/components/app/actions/ActionIcon.svelte b/tools/ui/src/lib/components/app/actions/ActionIcon.svelte index 8a86557bb..608ff6fab 100644 --- a/tools/ui/src/lib/components/app/actions/ActionIcon.svelte +++ b/tools/ui/src/lib/components/app/actions/ActionIcon.svelte @@ -66,7 +66,14 @@ {#snippet child({ props })} - {@render button(props)} + {#if disabled} + + + {@render button({})} + + {:else} + {@render button(props)} + {/if} {/snippet} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte index 9d3d07a27..36798e228 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte @@ -157,10 +157,7 @@ > {#if currentConfig.renderUserContentAsMarkdown}
- +
{:else} {#if renderMarkdown && currentConfig.renderUserContentAsMarkdown}
- +
{:else} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte index 34c82cd76..751d13756 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte @@ -43,6 +43,7 @@ const renderThinkingAsMarkdown = $derived(config().renderThinkingAsMarkdown as boolean); const showThoughtInProgress = $derived(Boolean(config().showThoughtInProgress)); + const alwaysShowToolCallContent = $derived(Boolean(config().alwaysShowToolCallContent)); const showMessageStats = $derived(Boolean(config().showMessageStats)); const showAgenticTurnStats = $derived(showMessageStats && Boolean(config().showAgenticTurnStats)); @@ -98,19 +99,6 @@ isStreaming ? agenticExecutingToolCallId(message.convId) : null ); - // Skip sections the user manually collapsed - we never override an explicit false. - let lastSeenExecutingToolCallId: string | null = null; - $effect(() => { - const current = currentlyExecutingToolCallId; - const previous = lastSeenExecutingToolCallId; - lastSeenExecutingToolCallId = current; - if (!current || current === previous) return; - const idx = sections.findIndex((s) => s.toolCallId === current); - if (idx >= 0 && expandedStates[idx] === undefined) { - expandedStates[idx] = true; - } - }); - type TurnGroup = { sections: AgenticSection[]; flatIndices: number[]; @@ -149,10 +137,11 @@ function getDefaultExpanded(section: AgenticSection): boolean { if ( + section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING || section.type === AgenticSectionType.TOOL_CALL_STREAMING ) { - return false; + return alwaysShowToolCallContent; } if (section.type === AgenticSectionType.REASONING_PENDING) { diff --git a/tools/ui/src/lib/components/app/dialogs/DialogConversationRename.svelte b/tools/ui/src/lib/components/app/dialogs/DialogConversationRename.svelte new file mode 100644 index 000000000..d85340f3f --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogConversationRename.svelte @@ -0,0 +1,85 @@ + + + + + + + + Rename conversation + + + Choose a new title for this conversation. + + +
+ + + +
+ + + Cancel + + + +
+
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte b/tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte index 737325085..5f5b2f4ab 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte @@ -37,9 +37,9 @@ - + - + Select Conversations to {mode === 'export' ? 'Export' : 'Import'} @@ -58,6 +58,7 @@ - import * as AlertDialog from '$lib/components/ui/alert-dialog'; - import { Button } from '$lib/components/ui/button'; - - interface Props { - open: boolean; - currentTitle: string; - newTitle: string; - onConfirm: () => void; - onCancel: () => void; - } - - let { open = $bindable(), currentTitle, newTitle, onConfirm, onCancel }: Props = $props(); - - - - - - Update Conversation Title? - - - Do you want to update the conversation title to match the first message content? - - - -
-
-

Current title:

- -

{currentTitle}

-
- -
-

New title would be:

- -

{newTitle}

-
-
- - - - - - -
-
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte b/tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte index c112bde9f..fe36dce56 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte @@ -68,6 +68,7 @@ Cancel + showTitleUpdate = false} + * bind:value={renameDraft} + * onConfirm={handleRenameConfirm} + * onCancel={() => (showRename = false)} * /> * ``` */ -export { default as DialogConversationTitleUpdate } from './DialogConversationTitleUpdate.svelte'; +export { default as DialogConversationRename } from './DialogConversationRename.svelte'; /** * diff --git a/tools/ui/src/lib/components/app/misc/ConversationSelection.svelte b/tools/ui/src/lib/components/app/misc/ConversationSelection.svelte index db14fd631..b6052b4a9 100644 --- a/tools/ui/src/lib/components/app/misc/ConversationSelection.svelte +++ b/tools/ui/src/lib/components/app/misc/ConversationSelection.svelte @@ -4,6 +4,7 @@ import SearchInput from '$lib/components/app/forms/SearchInput.svelte'; import { ScrollArea } from '$lib/components/ui/scroll-area'; import { SvelteSet } from 'svelte/reactivity'; + import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte'; interface Props { conversations: DatabaseConversation[]; @@ -11,13 +12,20 @@ mode: 'export' | 'import'; onCancel: () => void; onConfirm: (selectedConversations: DatabaseConversation[]) => void; + isOpen?: boolean; } - let { conversations, messageCountMap = new Map(), mode, onCancel, onConfirm }: Props = $props(); + let { + conversations, + messageCountMap = new Map(), + mode, + onCancel, + onConfirm, + isOpen = true + }: Props = $props(); let searchQuery = $state(''); let selectedIds = $state.raw>(getInitialSelectedIds()); - let lastClickedId = $state(null); function getInitialSelectedIds(): SvelteSet { return new SvelteSet(conversations.map((c) => c.id)); @@ -30,6 +38,8 @@ }) ); + let orderedIds = $derived(filteredConversations.map((c) => c.id)); + let allSelected = $derived( filteredConversations.length > 0 && filteredConversations.every((conv) => selectedIds.has(conv.id)) @@ -39,54 +49,20 @@ filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected ); - function toggleConversation(id: string, shiftKey: boolean = false) { - const newSet = new SvelteSet(selectedIds); - - if (shiftKey && lastClickedId !== null) { - const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId); - const currentIndex = filteredConversations.findIndex((c) => c.id === id); - - if (lastIndex !== -1 && currentIndex !== -1) { - const start = Math.min(lastIndex, currentIndex); - const end = Math.max(lastIndex, currentIndex); - - const shouldSelect = !newSet.has(id); - - for (let i = start; i <= end; i++) { - if (shouldSelect) { - newSet.add(filteredConversations[i].id); - } else { - newSet.delete(filteredConversations[i].id); - } - } - - selectedIds = newSet; - return; - } - } - - if (newSet.has(id)) { - newSet.delete(id); - } else { - newSet.add(id); - } - - selectedIds = newSet; - lastClickedId = id; - } + const marquee = useMarqueeSelection({ + selectedIds: () => selectedIds, + orderedIds: () => orderedIds, + enabled: () => isOpen + }); function toggleAll() { + const newSet = new SvelteSet(selectedIds); if (allSelected) { - const newSet = new SvelteSet(selectedIds); - filteredConversations.forEach((conv) => newSet.delete(conv.id)); - selectedIds = newSet; } else { - const newSet = new SvelteSet(selectedIds); - filteredConversations.forEach((conv) => newSet.add(conv.id)); - selectedIds = newSet; } + selectedIds = newSet; } function handleConfirm() { @@ -97,7 +73,7 @@ function handleCancel() { selectedIds = getInitialSelectedIds(); searchQuery = ''; - lastClickedId = null; + marquee.reset(); onCancel(); } @@ -105,7 +81,7 @@ export function reset() { selectedIds = getInitialSelectedIds(); searchQuery = ''; - lastClickedId = null; + marquee.reset(); } @@ -122,7 +98,7 @@
- + @@ -139,6 +115,7 @@ + {#if filteredConversations.length === 0} @@ -152,23 +129,28 @@ {:else} {#each filteredConversations as conv (conv.id)} + {@const checked = selectedIds.has(conv.id)} toggleConversation(conv.id, event.shiftKey)} + class="cursor-pointer border-b transition-colors hover:bg-muted/50 {checked + ? 'bg-muted/75' + : ''}" + data-conversation-row={conv.id} + onmousedown={(event) => marquee.rowMouseDown(conv.id, event)} + onclick={(event) => marquee.rowClick(conv.id, event.shiftKey)} > diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte index a23f4682e..b5e4beeff 100644 --- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte +++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte @@ -4,15 +4,22 @@ import { PanelLeftClose, PanelLeftOpen, X } from '@lucide/svelte'; import { ActionIcon, + DialogConversationRename, Logo, SidebarNavigationConversationList, SidebarNavigationActions } from '$lib/components/app'; import { ROUTES } from '$lib/constants'; import { fade } from 'svelte/transition'; + import { SvelteSet } from 'svelte/reactivity'; + import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte'; import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte'; - import { conversationsStore, conversations } from '$lib/stores/conversations.svelte'; + import { + buildConversationTree, + conversationsStore, + conversations + } from '$lib/stores/conversations.svelte'; import { chatStore } from '$lib/stores/chat.svelte'; import { config } from '$lib/stores/settings.svelte'; import { RouterService } from '$lib/services/router.service'; @@ -40,7 +47,6 @@ const isOnMobile = $derived(isMobile.current); const alwaysShowOnDesktop = $derived(config().alwaysShowSidebarOnDesktop as boolean); - // Keep the sidebar expanded on desktop when the user pins it open $effect(() => { if (alwaysShowOnDesktop && !isOnMobile) { isExpandedMode = true; @@ -58,13 +64,11 @@ if (!isExpandedMode) { isSearchModeActive = false; searchQuery = ''; + if (isSelectionMode) exitSelectionMode(); cancelMobileCollapse(); } }); - // On mobile the dedicated /search route hides the sidebar (see the aside - // render guard below). Collapse it as we enter /search so it doesn't - // reappear expanded when the user navigates back via the back button. $effect(() => { if (isMobile.current && page.url.hash.includes(ROUTES.SEARCH)) { isExpandedMode = false; @@ -89,6 +93,121 @@ return conversations(); }); + let isSelectionMode = $state(false); + let selectedIds = new SvelteSet(); + + let renameDialogOpen = $state(false); + let renameTargetConversationId = $state(null); + let renameDraft = $state(''); + let renameOriginalTitle = $state(''); + + const renderedOrderIds = $derived( + buildConversationTree(filteredConversations).map((t) => t.conversation.id) + ); + + const allSelectedArePinned = $derived.by(() => { + if (selectedIds.size === 0) return false; + const convs = conversations(); + for (const id of selectedIds) { + const c = convs.find((conv) => conv.id === id); + if (c && !c.pinned) return false; + } + return true; + }); + + const pinStateIsMixed = $derived.by(() => { + if (selectedIds.size === 0) return false; + const convs = conversations(); + let anyPinned = false; + let anyUnpinned = false; + for (const id of selectedIds) { + const c = convs.find((conv) => conv.id === id); + if (!c) continue; + if (c.pinned) anyPinned = true; + else anyUnpinned = true; + if (anyPinned && anyUnpinned) return true; + } + return false; + }); + + const visibleSelectionStats = $derived.by(() => { + const visibleIds = filteredConversations.map((c) => c.id); + let selectedVisible = 0; + for (const id of visibleIds) { + if (selectedIds.has(id)) selectedVisible++; + } + return { + visibleCount: visibleIds.length, + selectedVisibleCount: selectedVisible + }; + }); + + function enterSelectionMode(id?: string) { + isSelectionMode = true; + if (id !== undefined) { + selectedIds.add(id); + } + } + + function exitSelectionMode() { + isSelectionMode = false; + selectedIds.clear(); + } + + function toggleSelected(id: string) { + if (selectedIds.has(id)) { + selectedIds.delete(id); + } else { + selectedIds.add(id); + } + } + + function toggleSelectAllVisible() { + const visibleIds = filteredConversations.map((c) => c.id); + const allSelected = visibleIds.length > 0 && visibleIds.every((id) => selectedIds.has(id)); + + if (allSelected) { + for (const id of visibleIds) selectedIds.delete(id); + } else { + for (const id of visibleIds) selectedIds.add(id); + } + } + + async function handleBulkDelete() { + const ids = Array.from(selectedIds); + if (ids.length === 0) return; + await conversationsStore.bulkDeleteConversations(ids); + exitSelectionMode(); + } + + async function handleBulkPinToggle() { + const ids = Array.from(selectedIds); + if (ids.length === 0) return; + await conversationsStore.bulkToggleConversationPin(ids); + } + + async function handleBulkExport() { + const ids = Array.from(selectedIds); + if (ids.length === 0) return; + await conversationsStore.bulkExportConversations(ids); + } + + const marquee = useMarqueeSelection({ + selectedIds: () => selectedIds, + orderedIds: () => renderedOrderIds, + enabled: () => isSelectionMode + }); + + function handleRowMouseDown(id: string, event: MouseEvent) { + if (!isSelectionMode) return; + marquee.rowMouseDown(id, event); + } + + function handleSelectionClick(id: string, options: { shiftKey: boolean }): void { + if (!isSelectionMode) return; + marquee.rowClick(id, options.shiftKey); + } + async function selectConversation(id: string) { if (isMobile.current) { scheduleMobileCollapse(); @@ -100,10 +219,30 @@ const conversation = conversations().find((conv) => conv.id === id); if (!conversation) return; - const newName = window.prompt('Rename conversation', conversation.name); - if (newName && newName.trim()) { - await conversationsStore.updateConversationName(id, newName.trim()); - } + renameTargetConversationId = id; + renameOriginalTitle = conversation.name; + renameDraft = conversation.name; + renameDialogOpen = true; + } + + async function handleRenameConfirm() { + const id = renameTargetConversationId; + if (!id) return; + + const nextName = renameDraft.trim(); + if (!nextName || nextName === renameOriginalTitle.trim()) return; + + await conversationsStore.updateConversationName(id, nextName); + + renameDialogOpen = false; + renameTargetConversationId = null; + } + + function handleRenameCancel() { + renameDialogOpen = false; + renameTargetConversationId = null; + renameDraft = ''; + renameOriginalTitle = ''; } async function handleDeleteConversation(id: string) { @@ -148,9 +287,7 @@ {#if innerWidth > 768 || (!page.url.hash.includes(ROUTES.SETTINGS) && !page.url.hash.includes(ROUTES.MCP_SERVERS) && !page.url.hash.includes(ROUTES.SEARCH))} {/if} + +
Messages
{ event.preventDefault(); event.stopPropagation(); - toggleConversation(conv.id, event.shiftKey); + marquee.rowClick(conv.id, event.shiftKey); }} /> -
+
{conv.name || 'Untitled conversation'}