Commit 281e7dad7 for llama.cpp
commit 281e7dad7cfe4539b13d0218f4b0fd5a03f3abc9
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date: Tue Nov 10 15:44:46 2026 +0100
ui: gather chats into named groups in the sidebar (#32012)
* ui: let a menu action open a submenu
Group and project pickers open as flyouts from a chat's menus. Master's menu
list has no submenus, so bring them back next to its tooltips, with a close
callback for picks made inside them.
Assisted-by: Claude (Opus 5.5 Medium)
* ui : store groups and let conversations join them
Groups live in their own IndexedDB table (schema version 2) with a name.
groupsStore creates, renames and deletes them; deleting a group keeps its
chats, out of any group. A conversation records the group it is sorted
into, and a group picked before the first message is kept pending and
applied when the conversation is created.
Assisted-by: Claude (Opus 5.5 Medium)
* ui : show each group as a sidebar section
Every group is a sidebar section next to Pinned and Recent, folding,
showing its first six chats and dragged into place like them. A chat
dropped on a group joins it and leaves Pinned; dropped on Recent, it
leaves its group. Recent is called Ungrouped once a group exists, and its
menus create a group in a small naming dialog; so does a right-click
anywhere in the list that no row or heading takes, down to the empty space
under the last section. A group's heading starts a new chat in it, and its
menus rename or delete it; deleting keeps its chats, back in Ungrouped.
Assisted-by: Claude (Opus 5.5 Medium)
* ui : move chats into groups from their menus
A chat's menus, in the sidebar and on right-click, move it into a group
or take it out of its group. Desktop menus show a searchable group flyout
whose New group entry names a group in the group dialog and moves the chat
into it; a phone gets a picker dialog instead, which creates a group from
what is typed.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: open the group picker from a phone's hold menu
The hold menu is anchored to the row, so it showed the desktop flyout,
which ran off the left edge of the screen and whose New group closed
without naming one. On a phone, Move to group now opens the picker
dialog, as intended.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: open a new chat in a tab of its own
With conversation tabs on, New chat from the sidebar, a group's heading
or the shortcut replaced the open chat's tab. It now adds a tab, as the
tab bar's "+" does, and the open chat keeps its own.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: list a phone's groups under the picker's search
On a phone the picker fills the screen, and its grid spread the rows
over the height, so the list sat in the middle. It is now a column: the
list starts under the search and scrolls in the rest of the screen.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: fix gaps in chat groups found in review
- A phone shows a group heading's New chat and menu, and the Ungrouped
heading's menu, which were reachable only by hover or right-click.
- A pinned chat moved into a group from its menu leaves Pinned, as a
drag onto the group already did, so the move shows.
- Only the sidebar's New chat, a group's New chat and the new-chat
shortcut open a new tab; stepping past the last chat, search and the
models drawer show the new-chat screen in the current tab again.
- An export carries each chat's group, and an import sorts the chat
into the group of that id or name, or creates it; a group the file
does not carry is dropped.
- The group picked with a group's New chat survives leaving the
new-chat tab and coming back.
- A phone's menus skip any submenu in the shared menu list, not only
the group one.
- Deleting a group forgets its section's place and fold.
- Groups load newest first by creation, so a rename no longer moves one
after a reload.
- groupsStore looks groups up in a map and answers which live group a
chat is in, for the three places that checked it by hand.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: keep a forked chat in its source chat's group
A fork copied the working directory but not the group, so it landed in
Ungrouped.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: move a chat with its whole fork family between groups
Moving a chat into a group, or out of one, takes its parents and its
children along, so a fork tree never splits across sidebar sections. A
pinned member that joins a group leaves Pinned with the rest. Group
sections render the tree with its depth, as Ungrouped does.
* ui: show the group name prompt as a small centered card on phones
The new and rename group prompt opened full screen on a phone, like a
large dialog. It is now built on the alert dialog, as the chat rename
prompt is, so a phone shows it as a card in the middle of the screen.
Its Cancel is a plain button, so Enter in the name field saves.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: space a phone's group sections like Ungrouped
On a phone a group or Pinned section put its first chat right under the
heading, where Ungrouped leaves a row gap. They now leave the same gap,
and the sections sit a little further apart than the rows inside them.
Assisted-by: Claude (Opus 5.5 Medium)
---------
Co-authored-by: Pascal <admin@serveurperso.com>
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogGroup.svelte b/tools/ui/src/lib/components/app/dialogs/DialogGroup.svelte
new file mode 100644
index 000000000..bc198768c
--- /dev/null
+++ b/tools/ui/src/lib/components/app/dialogs/DialogGroup.svelte
@@ -0,0 +1,103 @@
+<script lang="ts">
+ import { FolderPlus, Pencil } from '@lucide/svelte';
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import { Button } from '$lib/components/ui/button';
+ import { Input } from '$lib/components/ui/input';
+ import { Label } from '$lib/components/ui/label';
+ import { GROUP_NAME_MAX_LENGTH } from '$lib/constants';
+ import { groupsStore, uiStore } from '$lib/stores';
+ import { untrack } from 'svelte';
+
+ let options = $derived(uiStore.groupDialog);
+ let group = $derived(options?.groupId ? groupsStore.get(options.groupId) : undefined);
+
+ let name = $state('');
+ let nameInput = $state<HTMLInputElement | null>(null);
+
+ // a fresh name every time the dialog opens, the group's own when renaming
+ $effect.pre(() => {
+ if (!options) return;
+
+ untrack(() => (name = group?.name ?? ''));
+ });
+
+ const canSave = $derived(name.trim().length > 0 && name.trim() !== group?.name);
+
+ function close() {
+ uiStore.groupDialog = null;
+ }
+
+ // reads the dialog's options before closing it, which clears them
+ async function handleSubmit(event: SubmitEvent) {
+ event.preventDefault();
+
+ if (!canSave || !options) return;
+
+ const { onCreate } = options;
+ const renamedId = group?.id;
+
+ close();
+
+ if (renamedId) {
+ await groupsStore.rename(renamedId, name);
+
+ return;
+ }
+
+ const created = await groupsStore.create(name);
+
+ if (created) onCreate?.(created);
+ }
+</script>
+
+<!-- a short prompt, so a phone shows it as a centered card like an alert, not full screen -->
+<AlertDialog.Root onOpenChange={(open) => !open && close()} open={options !== null}>
+ <AlertDialog.Content
+ class="sm:max-w-md"
+ onOpenAutoFocus={(event) => {
+ event.preventDefault();
+ nameInput?.focus();
+ nameInput?.select();
+ }}
+ >
+ <AlertDialog.Header>
+ <AlertDialog.Title class="flex items-center gap-2">
+ {#if group}
+ <Pencil class="h-5 w-5" />
+ {:else}
+ <FolderPlus class="h-5 w-5" />
+ {/if}
+
+ {group ? 'Rename group' : 'New group'}
+ </AlertDialog.Title>
+
+ <AlertDialog.Description>
+ {group ? 'Choose a new name for this group.' : 'Name a group to sort chats into.'}
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+
+ <form class="contents" onsubmit={handleSubmit}>
+ <div class="flex flex-col gap-2">
+ <Label for="group-name">Name</Label>
+
+ <Input
+ bind:ref={nameInput}
+ bind:value={name}
+ autocomplete="off"
+ id="group-name"
+ maxlength={GROUP_NAME_MAX_LENGTH}
+ placeholder="e.g. Research"
+ />
+ </div>
+
+ <AlertDialog.Footer>
+ <!-- not a submit button, or Enter in the name field would cancel -->
+ <AlertDialog.Cancel type="button">Cancel</AlertDialog.Cancel>
+
+ <Button disabled={!canSave} type="submit">
+ {group ? 'Save' : 'Create group'}
+ </Button>
+ </AlertDialog.Footer>
+ </form>
+ </AlertDialog.Content>
+</AlertDialog.Root>
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogGroupPicker.svelte b/tools/ui/src/lib/components/app/dialogs/DialogGroupPicker.svelte
new file mode 100644
index 000000000..e541b793b
--- /dev/null
+++ b/tools/ui/src/lib/components/app/dialogs/DialogGroupPicker.svelte
@@ -0,0 +1,94 @@
+<script lang="ts">
+ import { Check, Plus } from '@lucide/svelte';
+ import { SearchInput } from '$lib/components/app';
+ import * as Dialog from '$lib/components/ui/dialog';
+ import { groupsStore, uiStore } from '$lib/stores';
+
+ let options = $derived(uiStore.groupPicker);
+ let query = $state('');
+
+ const groups = $derived.by(() => {
+ const needle = query.trim().toLowerCase();
+ const all = [...groupsStore.groups].sort((a, b) => a.name.localeCompare(b.name));
+
+ return needle ? all.filter((group) => group.name.toLowerCase().includes(needle)) : all;
+ });
+
+ const newGroupName = $derived(query.trim());
+
+ function close() {
+ uiStore.groupPicker = null;
+ query = '';
+ }
+
+ function select(groupId: string) {
+ const onSelect = options?.onSelect;
+
+ close();
+ onSelect?.(groupId);
+ }
+
+ async function createAndSelect() {
+ if (!newGroupName) return;
+
+ const group = await groupsStore.create(newGroupName);
+
+ if (group) select(group.id);
+ }
+</script>
+
+<!-- a column, so on a phone the list starts under the search and takes the rest of the screen -->
+<Dialog.Root onOpenChange={(open) => !open && close()} open={options !== null}>
+ <Dialog.Content class="flex flex-col gap-0 overflow-hidden p-0 md:max-w-md!">
+ <Dialog.Title class="sr-only">Move to group</Dialog.Title>
+
+ <div class="p-2">
+ <SearchInput
+ bind:value={query}
+ autofocus
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' && newGroupName) void createAndSelect();
+ }}
+ placeholder="Search or create a group"
+ size="sm"
+ />
+ </div>
+
+ <div
+ class="flex max-h-80 min-h-0 flex-col gap-0.5 overflow-y-auto p-1 max-md:max-h-none max-md:flex-1"
+ >
+ <button
+ class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent disabled:pointer-events-none disabled:opacity-50"
+ disabled={!newGroupName}
+ onclick={createAndSelect}
+ type="button"
+ >
+ <Plus class="h-4 w-4 shrink-0" />
+
+ <span class="min-w-0 truncate">
+ {newGroupName ? `Create "${newGroupName}"` : 'New group'}
+ </span>
+ </button>
+
+ {#if groups.length > 0}
+ <div aria-hidden="true" class="mx-2 my-1 h-px bg-border/40"></div>
+
+ {#each groups as group (group.id)}
+ <button
+ class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent"
+ onclick={() => select(group.id)}
+ type="button"
+ >
+ <span class="min-w-0 flex-1 truncate">{group.name}</span>
+
+ {#if group.id === options?.currentGroupId}
+ <Check class="h-4 w-4 shrink-0 text-primary" />
+ {/if}
+ </button>
+ {/each}
+ {:else if newGroupName}
+ <p class="px-2 py-2 text-sm text-muted-foreground">No matching groups</p>
+ {/if}
+ </div>
+ </Dialog.Content>
+</Dialog.Root>
diff --git a/tools/ui/src/lib/components/app/dialogs/index.ts b/tools/ui/src/lib/components/app/dialogs/index.ts
index ca46ba753..5b3ca761d 100644
--- a/tools/ui/src/lib/components/app/dialogs/index.ts
+++ b/tools/ui/src/lib/components/app/dialogs/index.ts
@@ -177,6 +177,24 @@ export { default as DialogConversationDelete } from './DialogConversationDelete.
*/
export { default as DialogConversationRename } from './DialogConversationRename.svelte';
+/**
+ * **DialogGroup** - Name a new sidebar group, or rename one
+ *
+ * Opened through `uiStore.openGroupDialog()`, from the Recent heading's
+ * menus to create a group and from a group's menus to rename it. Mounted
+ * once, in the sidebar.
+ */
+export { default as DialogGroup } from './DialogGroup.svelte';
+
+/**
+ * **DialogGroupPicker** - Pick the group a chat moves to, on a phone
+ *
+ * Opened through `uiStore.openGroupPicker()` where a desktop menu shows the
+ * group submenu: a search that filters the groups and creates one with
+ * the typed name. Mounted once, in the sidebar.
+ */
+export { default as DialogGroupPicker } from './DialogGroupPicker.svelte';
+
/**
*
* CONTENT PREVIEW DIALOGS
diff --git a/tools/ui/src/lib/components/app/navigation/ContextMenuActions.svelte b/tools/ui/src/lib/components/app/navigation/ContextMenuActions.svelte
index ca0f7de60..dd68cb425 100644
--- a/tools/ui/src/lib/components/app/navigation/ContextMenuActions.svelte
+++ b/tools/ui/src/lib/components/app/navigation/ContextMenuActions.svelte
@@ -26,6 +26,6 @@
</ContextMenu.Trigger>
<ContextMenu.Content class="z-[999999] w-48">
- <MenuActionsList {actions} kind={MenuKind.CONTEXT} />
+ <MenuActionsList {actions} kind={MenuKind.CONTEXT} onClose={() => (open = false)} />
</ContextMenu.Content>
</ContextMenu.Root>
diff --git a/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte b/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte
index 7dd6ea6ad..158c55858 100644
--- a/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte
+++ b/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte
@@ -129,7 +129,7 @@
</Tooltip.Root>
<DropdownMenu.Content {align} class="z-[999999] w-48" customAnchor={anchor}>
- <MenuActionsList {actions} kind={MenuKind.DROPDOWN} />
+ <MenuActionsList {actions} kind={MenuKind.DROPDOWN} onClose={() => (open = false)} />
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
diff --git a/tools/ui/src/lib/components/app/navigation/MenuActionsList.svelte b/tools/ui/src/lib/components/app/navigation/MenuActionsList.svelte
index c48076bb9..7a0ba8a4d 100644
--- a/tools/ui/src/lib/components/app/navigation/MenuActionsList.svelte
+++ b/tools/ui/src/lib/components/app/navigation/MenuActionsList.svelte
@@ -4,15 +4,18 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { MenuKind } from '$lib/enums';
+ import { deviceStore } from '$lib/stores';
import type { MenuAction } from '$lib/types';
import type { Component } from 'svelte';
interface Props {
actions: MenuAction[];
kind: MenuKind;
+ /** Closes the menu, e.g. after a pick in a submenu. */
+ onClose: () => void;
}
- let { actions, kind }: Props = $props();
+ let { actions, kind, onClose }: Props = $props();
// both kits wrap the same bits-ui menu parts, so the rows render alike
let Menu = $derived(kind === MenuKind.CONTEXT ? ContextMenu : DropdownMenu);
@@ -23,7 +26,10 @@
<Menu.Separator />
{/if}
- {#if action.tooltip}
+ <!-- a flyout would run off a phone's screen, so there the row runs its onclick -->
+ {#if action.submenu && !deviceStore.isMobile}
+ {@render action.submenu({ close: onClose, kind })}
+ {:else if action.tooltip}
<!-- a disabled item takes no pointer events, so the wrapper holds the hover -->
<Tooltip.Root>
<Tooltip.Trigger>
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 661a3df90..773f40448 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
@@ -7,6 +7,8 @@
DialogConversationDelete,
DialogConversationRename,
DialogDiscoverModels,
+ DialogGroup,
+ DialogGroupPicker,
DialogMcpServers,
DialogSearch,
DialogSettingsChat,
@@ -502,7 +504,7 @@
function handleSidebarAction(action: SidebarAction): void {
switch (action) {
case SidebarAction.NEW_CHAT:
- afterMobileClose(() => void conversationsStore.openNewChat());
+ afterMobileClose(() => void conversationsStore.openNewChat({ inNewTab: true }));
break;
case SidebarAction.SEARCH:
@@ -707,6 +709,10 @@
<DialogDiscoverModels bind:open={uiStore.discoverModelsOpen} />
+<DialogGroup />
+
+<DialogGroupPicker />
+
<ModelsManagerModelDrawer />
<style>
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
index e804ed91e..8ab731487 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
@@ -1,6 +1,8 @@
<script lang="ts">
+ import SidebarNavigationGroupSubmenu from './SidebarNavigationGroupSubmenu.svelte';
import {
Download,
+ FolderInput,
GitBranch,
ListChecks,
Loader2,
@@ -10,7 +12,8 @@
Pin,
PinOff,
Square,
- Trash2
+ Trash2,
+ Unlink
} from '@lucide/svelte';
import { ContextMenuActions, DropdownMenuActions } from '$lib/components/app';
import { TruncatedText } from '$lib/components/app';
@@ -25,15 +28,18 @@
SHORTCUT_EXPORT_CONVERSATION,
UI_DATA_ATTRS
} from '$lib/constants';
+ import type { MenuKind } from '$lib/enums';
import { useLongPress } from '$lib/hooks/use-long-press.svelte';
import { RouterService } from '$lib/services/router.service';
import {
chatStore,
conversationsStore,
deviceStore,
+ groupsStore,
settingsStore,
sidebarDragStore,
- tabsStore
+ tabsStore,
+ uiStore
} from '$lib/stores';
import type { MenuAction } from '$lib/types';
import { hapticTap } from '$lib/utils';
@@ -134,6 +140,41 @@
openInNewTab();
}
+ function setGroup(groupId: string | null) {
+ void conversationsStore.setConversationGroup(conversation.id, groupId);
+ }
+
+ // a group the chat points at may be gone; then it is in none
+ let groupId = $derived(groupsStore.groupIdOf(conversation));
+
+ // the picker stands in for the submenu where a phone's menu shows no flyouts
+ function handlePickGroup(event: Event) {
+ event.stopPropagation();
+ uiStore.openGroupPicker({ currentGroupId: groupId, onSelect: setGroup });
+ }
+
+ // a chat in a group can move to another one or leave it, a loose chat can join one
+ let groupActions = $derived<MenuAction[]>([
+ {
+ icon: FolderInput,
+ label: 'Move to group',
+ onclick: handlePickGroup,
+ submenu: groupSubmenu
+ },
+ ...(groupId
+ ? [
+ {
+ icon: Unlink,
+ label: 'Remove from group',
+ onclick: (event: Event) => {
+ event.stopPropagation();
+ setGroup(null);
+ }
+ }
+ ]
+ : [])
+ ]);
+
// the "more actions" dropdown and the right-click menu offer the same actions; the
// shortcuts act on the active conversation, so only its row shows them
let actions = $derived<MenuAction[]>([
@@ -164,6 +205,7 @@
onclick: handleEdit,
shortcut: isActive ? SHORTCUT_EDIT_CONVERSATION : undefined
},
+ ...groupActions,
{
icon: Download,
label: 'Export',
@@ -398,6 +440,16 @@
</button>
</ContextMenuActions>
+{#snippet groupSubmenu({ close, kind }: { close: () => void; kind: MenuKind })}
+ <SidebarNavigationGroupSubmenu
+ currentGroupId={groupId}
+ {kind}
+ label="Move to group"
+ onClose={close}
+ onSelect={setGroup}
+ />
+{/snippet}
+
<style>
button {
&.is-selection-mode :global([data-slot='dropdown-menu-trigger']) {
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationList.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationList.svelte
index cfd9f082e..03f8be318 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationList.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationList.svelte
@@ -1,11 +1,12 @@
<script lang="ts">
import SidebarNavigationConversationItem from './SidebarNavigationConversationItem.svelte';
import SidebarNavigationDropZone from './SidebarNavigationDropZone.svelte';
+ import SidebarNavigationGroupSection from './SidebarNavigationGroupSection.svelte';
import SidebarNavigationSectionHeading from './SidebarNavigationSectionHeading.svelte';
import SidebarNavigationSelectionBar from './SidebarNavigationSelectionBar.svelte';
import SidebarNavigationShowMore from './SidebarNavigationShowMore.svelte';
- import { Pin } from '@lucide/svelte';
- import { CollapsibleRegion } from '$lib/components/app';
+ import { FolderPlus, MoreHorizontal, Pin } from '@lucide/svelte';
+ import { CollapsibleRegion, ContextMenuActions, DropdownMenuActions } from '$lib/components/app';
import {
SIDEBAR_ROW_SLIDE_MS,
SIDEBAR_SECTION_IDS,
@@ -13,7 +14,14 @@
SIDEBAR_SHOW_MORE_ROW_CLASS
} from '$lib/constants';
import { SidebarSectionKind } from '$lib/enums';
- import { conversationsStore, sidebarDragStore, uiStore } from '$lib/stores';
+ import {
+ conversationsStore,
+ deviceStore,
+ groupsStore,
+ sidebarDragStore,
+ uiStore
+ } from '$lib/stores';
+ import type { MenuAction } from '$lib/types';
import { buildConversationTree } from '$lib/utils';
import { flip } from 'svelte/animate';
import { slide } from 'svelte/transition';
@@ -70,25 +78,57 @@
visibleCount
}: Props = $props();
+ let recentMenuOpen = $state(false);
+ let recentDropdownOpen = $state(false);
+ let listMenuOpen = $state(false);
+
+ // the Recent heading's "more actions" dropdown and its right-click menu, and
+ // a right-click anywhere else in the list that no row or heading takes
+ const recentActions: MenuAction[] = [
+ { icon: FolderPlus, label: 'New group', onclick: () => uiStore.openGroupDialog() }
+ ];
+
let conversationTree = $derived(buildConversationTree(conversations));
+ // beside group sections it holds the chats in none of them
+ let recentLabel = $derived(groupsStore.groups.length > 0 ? 'Ungrouped' : 'Recent conversations');
+
+ // Every group is a section of its own, between Pinned and Recent. A pinned
+ // chat stays in Pinned; any other chat in a group shows in its section only,
+ // with its forks nested under it as in Recent.
+ let groupSections = $derived(
+ groupsStore.groups.map((group) => ({
+ group,
+ items: conversationTree.filter(
+ ({ conversation }) => conversation.groupId === group.id && !conversation.pinned
+ )
+ }))
+ );
+
let pinnedConversations = $derived(
conversationTree.filter(({ conversation }) => conversation.pinned)
);
let unpinnedConversations = $derived(
- conversationTree.filter(({ conversation }) => !conversation.pinned)
+ conversationTree.filter(
+ ({ conversation }) => !conversation.pinned && !groupsStore.groupIdOf(conversation)
+ )
);
/** How long the other sections take to slide aside for a moved one. */
const SECTION_MOVE_MS = 200;
- // Pinned, then Recent by default; dragging a heading reorders them
+ // Pinned, the groups, then Recent by default; dragging a heading reorders them
let savedSections = $derived(
uiStore.orderSidebarSections([
...(pinnedConversations.length > 0
? [{ id: SIDEBAR_SECTION_IDS.PINNED as string, kind: SidebarSectionKind.PINNED as const }]
: []),
+ ...groupSections.map((section) => ({
+ ...section,
+ id: `${SIDEBAR_SECTION_IDS.GROUP_PREFIX}${section.group.id}`,
+ kind: SidebarSectionKind.GROUP as const
+ })),
{ id: SIDEBAR_SECTION_IDS.RECENT as string, kind: SidebarSectionKind.RECENT as const }
])
);
@@ -123,10 +163,11 @@
return Boolean(findConversation(convId)?.pinned);
}
- function isUnpinned(convId: string): boolean {
+ // the chats Ungrouped shows: neither pinned nor in a group that is still there
+ function isUngrouped(convId: string): boolean {
const conversation = findConversation(convId);
- return Boolean(conversation && !conversation.pinned);
+ return Boolean(conversation && !conversation.pinned && !groupsStore.groupIdOf(conversation));
}
/** Dropping on Pinned pins the chat, wherever it came from. */
@@ -137,57 +178,73 @@
await conversationsStore.toggleConversationPin(convId);
}
- /** Dropping on Recent unpins the chat. */
+ /** Dropping on Recent unpins the chat and takes it out of its group. */
async function handleDropRecent(convId: string) {
const conversation = conversationsStore.conversations.find((item) => item.id === convId);
- if (conversation?.pinned) await conversationsStore.toggleConversationPin(convId);
+ if (!conversation) return;
+
+ if (conversation.pinned) await conversationsStore.toggleConversationPin(convId);
+
+ if (conversation.groupId) await conversationsStore.setConversationGroup(convId, null);
}
</script>
-<div class="flex flex-col gap-4">
- {#each sections as section (section.id)}
- {@const isDragged = section.id === sidebarDragStore.sectionId}
-
- <!-- the dragged section follows the pointer and, let go, glides into its slot;
- the others slide aside, the dragged one is already where the pointer is -->
- <div
- animate:flip={{ duration: isDragged ? 0 : SECTION_MOVE_MS }}
- style:transform={isDragged ? `translateY(${sidebarDragStore.sectionOffset}px)` : undefined}
- class={[
- // opaque under its tint, so the sections it passes do not show through
- isDragged && 'sidebar-drag-surface relative z-20 rounded-lg',
- isDragged &&
- sidebarDragStore.settling &&
- 'transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]'
- ]}
- data-sidebar-slot={section.id}
- >
- {#if section.kind === SidebarSectionKind.PINNED}
- {@render pinnedSection()}
- {:else}
- {@render recentSection()}
- {/if}
- </div>
- {/each}
-
- {#if isSelectionMode}
- <SidebarNavigationSelectionBar
- {allVisibleSelected}
- class="sticky top-0 z-10 m-2 mt-0"
- {onBulkDelete}
- {onBulkExport}
- {onBulkPinToggle}
- onClose={onCloseSelection}
- {onSelectAllToggle}
- {pinStateIsMixed}
- selectedCount={selectedIds.size}
- someSelectedPinned={allSelectedArePinned}
- {someVisibleSelected}
- {visibleCount}
- />
- {/if}
-</div>
+<!-- rows and headings open their own menus; anywhere else, down to the empty
+ space under the last section, offers the same actions as the Recent heading -->
+<ContextMenuActions bind:open={listMenuOpen} actions={recentActions} disabled={isSelectionMode}>
+ <!-- a phone spaces its sections a little wider than the rows inside them -->
+ <div class="flex flex-1 flex-col gap-6 md:gap-4">
+ {#each sections as section (section.id)}
+ {@const isDragged = section.id === sidebarDragStore.sectionId}
+
+ <!-- the dragged section follows the pointer and, let go, glides into its slot;
+ the others slide aside, the dragged one is already where the pointer is -->
+ <div
+ animate:flip={{ duration: isDragged ? 0 : SECTION_MOVE_MS }}
+ style:transform={isDragged ? `translateY(${sidebarDragStore.sectionOffset}px)` : undefined}
+ class={[
+ // opaque under its tint, so the sections it passes do not show through
+ isDragged && 'sidebar-drag-surface relative z-20 rounded-lg',
+ isDragged &&
+ sidebarDragStore.settling &&
+ 'transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]'
+ ]}
+ data-sidebar-slot={section.id}
+ >
+ {#if section.kind === SidebarSectionKind.PINNED}
+ {@render pinnedSection()}
+ {:else if section.kind === SidebarSectionKind.GROUP}
+ <SidebarNavigationGroupSection
+ class={className}
+ conversationRow={conversationItem}
+ group={section.group}
+ items={section.items}
+ />
+ {:else}
+ {@render recentSection()}
+ {/if}
+ </div>
+ {/each}
+
+ {#if isSelectionMode}
+ <SidebarNavigationSelectionBar
+ {allVisibleSelected}
+ class="sticky top-0 z-10 m-2 mt-0"
+ {onBulkDelete}
+ {onBulkExport}
+ {onBulkPinToggle}
+ onClose={onCloseSelection}
+ {onSelectAllToggle}
+ {pinStateIsMixed}
+ selectedCount={selectedIds.size}
+ someSelectedPinned={allSelectedArePinned}
+ {someVisibleSelected}
+ {visibleCount}
+ />
+ {/if}
+ </div>
+</ContextMenuActions>
{#snippet showMore(sectionId: string, total: number)}
{#if total > SIDEBAR_SECTION_PREVIEW_COUNT}
@@ -206,6 +263,7 @@
conversation={{
currNode: conversation.currNode,
forkedFromConversationId: conversation.forkedFromConversationId,
+ groupId: conversation.groupId,
id: conversation.id,
lastModified: conversation.lastModified,
name: conversation.name,
@@ -228,7 +286,7 @@
{#snippet pinnedSection()}
<SidebarNavigationDropZone
- class="flex flex-col"
+ class="flex flex-col gap-4 md:gap-0"
holds={(convId) => isPinned(convId)}
onDrop={handleDropPinned}
sectionId={SIDEBAR_SECTION_IDS.PINNED}
@@ -262,19 +320,38 @@
{#snippet recentSection()}
<SidebarNavigationDropZone
class="flex flex-col gap-4 md:gap-0 whitespace-nowrap {className}"
- holds={(convId) => isUnpinned(convId)}
+ holds={(convId) => isUngrouped(convId)}
onDrop={handleDropRecent}
sectionId={SIDEBAR_SECTION_IDS.RECENT}
>
{#snippet heading()}
- {#if conversations.length > 0}
+ <!-- shown even with no chats yet: its menu is where a group is created -->
+ <ContextMenuActions bind:open={recentMenuOpen} actions={recentActions}>
<SidebarNavigationSectionHeading
dragId={SIDEBAR_SECTION_IDS.RECENT}
- label="Recent conversations"
+ highlighted={recentMenuOpen || recentDropdownOpen}
+ label={recentLabel}
onToggle={() => uiStore.toggleSidebarSection(SIDEBAR_SECTION_IDS.RECENT)}
open={uiStore.isSidebarSectionOpen(SIDEBAR_SECTION_IDS.RECENT)}
- />
- {/if}
+ >
+ {#snippet trailing()}
+ <!-- shown on hover, and always on a phone, which has none -->
+ <div
+ class="opacity-0 transition-opacity group-focus-within/heading:opacity-100 group-hover/heading:opacity-100 {recentDropdownOpen ||
+ deviceStore.isMobile
+ ? 'opacity-100'
+ : ''}"
+ >
+ <DropdownMenuActions
+ bind:open={recentDropdownOpen}
+ actions={recentActions}
+ triggerIcon={MoreHorizontal}
+ triggerTooltip="{recentLabel} actions"
+ />
+ </div>
+ {/snippet}
+ </SidebarNavigationSectionHeading>
+ </ContextMenuActions>
{/snippet}
<CollapsibleRegion open={uiStore.isSidebarSectionOpen(SIDEBAR_SECTION_IDS.RECENT)}>
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationGroupSection.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationGroupSection.svelte
new file mode 100644
index 000000000..46d066e7b
--- /dev/null
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationGroupSection.svelte
@@ -0,0 +1,176 @@
+<script lang="ts">
+ import SidebarNavigationDropZone from './SidebarNavigationDropZone.svelte';
+ import SidebarNavigationSectionHeading from './SidebarNavigationSectionHeading.svelte';
+ import SidebarNavigationShowMore from './SidebarNavigationShowMore.svelte';
+ import { MoreHorizontal, Pencil, Plus, SquarePen, Trash2 } from '@lucide/svelte';
+ import {
+ ActionIcon,
+ CollapsibleRegion,
+ ContextMenuActions,
+ DialogConfirmation,
+ DropdownMenuActions
+ } from '$lib/components/app';
+ import {
+ SIDEBAR_ROW_SLIDE_MS,
+ SIDEBAR_SECTION_IDS,
+ SIDEBAR_SECTION_PREVIEW_COUNT,
+ SIDEBAR_SHOW_MORE_ROW_CLASS
+ } from '$lib/constants';
+ import {
+ conversationsStore,
+ deviceStore,
+ groupsStore,
+ sidebarDragStore,
+ uiStore
+ } from '$lib/stores';
+ import type { DatabaseConversation, Group, MenuAction } from '$lib/types';
+ import type { ConversationTreeItem } from '$lib/utils';
+ import type { Snippet } from 'svelte';
+ import { slide } from 'svelte/transition';
+
+ interface Props {
+ class?: string;
+ group: Group;
+ /** The group's chats, forks nested under their parents. */
+ items: ConversationTreeItem[];
+ /** Row renderer from the list, which owns the per-chat actions. */
+ conversationRow: Snippet<[DatabaseConversation, number]>;
+ }
+
+ let { class: className = '', conversationRow, group, items }: Props = $props();
+
+ let contextMenuOpen = $state(false);
+ let dropdownOpen = $state(false);
+ let confirmDeleteOpen = $state(false);
+
+ let sectionId = $derived(`${SIDEBAR_SECTION_IDS.GROUP_PREFIX}${group.id}`);
+ let open = $derived(uiStore.isSidebarSectionOpen(sectionId));
+ let expanded = $derived(uiStore.isSidebarSectionExpanded(sectionId));
+ let shownItems = $derived.by(() => {
+ // a dragged chat leaves its section until it is dropped somewhere
+ const shown = items.filter(({ conversation }) => conversation.id !== sidebarDragStore.convId);
+
+ return expanded ? shown : shown.slice(0, SIDEBAR_SECTION_PREVIEW_COUNT);
+ });
+
+ async function startChat() {
+ await conversationsStore.openNewChat({ inNewTab: true });
+ await conversationsStore.assignActiveConversationGroup(group.id);
+ }
+
+ async function handleDelete() {
+ confirmDeleteOpen = false;
+ await groupsStore.remove(group.id);
+ }
+
+ /** A chat dropped on the section joins the group, and leaves Pinned. */
+ async function handleDrop(convId: string) {
+ await conversationsStore.setConversationGroup(convId, group.id);
+ }
+
+ // the heading's "more actions" dropdown and its right-click menu offer the same actions
+ const actions: MenuAction[] = [
+ { icon: SquarePen, label: 'New chat', onclick: () => void startChat() },
+ {
+ icon: Pencil,
+ label: 'Rename',
+ onclick: () => uiStore.openGroupDialog({ groupId: group.id }),
+ separator: true
+ },
+ {
+ icon: Trash2,
+ label: 'Delete',
+ onclick: () => (confirmDeleteOpen = true),
+ separator: true,
+ variant: 'destructive'
+ }
+ ];
+</script>
+
+<!-- a group is a section like Pinned and Recent: its chats show here only,
+ and a chat dropped anywhere on it joins the group -->
+<SidebarNavigationDropZone
+ class="flex min-w-0 flex-col gap-4 md:gap-0"
+ holds={(convId) => items.some(({ conversation }) => conversation.id === convId)}
+ onDrop={handleDrop}
+ {sectionId}
+>
+ {#snippet heading()}
+ <ContextMenuActions bind:open={contextMenuOpen} {actions}>
+ <SidebarNavigationSectionHeading
+ class={className}
+ dragId={sectionId}
+ highlighted={contextMenuOpen || dropdownOpen}
+ label={group.name}
+ onToggle={() => uiStore.toggleSidebarSection(sectionId)}
+ {open}
+ >
+ {#snippet trailing()}
+ <!-- shown on hover, and always on a phone, which has none -->
+ <div
+ class="flex items-center gap-0.5 opacity-0 transition-opacity group-focus-within/heading:opacity-100 group-hover/heading:opacity-100 {dropdownOpen ||
+ deviceStore.isMobile
+ ? 'opacity-100'
+ : ''}"
+ >
+ <ActionIcon
+ ariaLabel="New chat in {group.name}"
+ class="h-6 w-6"
+ icon={Plus}
+ iconSize="h-3 w-3"
+ onclick={() => void startChat()}
+ tooltip="New chat in group"
+ />
+
+ <DropdownMenuActions
+ bind:open={dropdownOpen}
+ {actions}
+ triggerIcon={MoreHorizontal}
+ triggerTooltip="Group actions"
+ />
+ </div>
+ {/snippet}
+ </SidebarNavigationSectionHeading>
+ </ContextMenuActions>
+ {/snippet}
+
+ <CollapsibleRegion {open}>
+ <ul class="flex w-full min-w-0 flex-col gap-4 md:gap-0.5 {className}">
+ {#each shownItems as { conversation, depth } (conversation.id)}
+ <li transition:slide={{ duration: SIDEBAR_ROW_SLIDE_MS }} class="group/item relative p-0">
+ {@render conversationRow(conversation, depth)}
+ </li>
+ {:else}
+ <li>
+ <button
+ class="w-full rounded-lg px-2 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground"
+ onclick={() => void startChat()}
+ type="button"
+ >
+ Start a chat in this group
+ </button>
+ </li>
+ {/each}
+
+ {#if items.length > SIDEBAR_SECTION_PREVIEW_COUNT}
+ <!-- only an opened section is long enough for its row to need to stick -->
+ <li class={expanded ? SIDEBAR_SHOW_MORE_ROW_CLASS : 'p-0'}>
+ <SidebarNavigationShowMore
+ {expanded}
+ onToggle={() => uiStore.toggleSidebarSectionExpanded(sectionId)}
+ />
+ </li>
+ {/if}
+ </ul>
+ </CollapsibleRegion>
+</SidebarNavigationDropZone>
+
+<DialogConfirmation
+ confirmText="Delete"
+ description="Its chats stay in your history, outside any group."
+ onCancel={() => (confirmDeleteOpen = false)}
+ onConfirm={handleDelete}
+ open={confirmDeleteOpen}
+ title="Delete {group.name}?"
+ variant="destructive"
+/>
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationGroupSubmenu.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationGroupSubmenu.svelte
new file mode 100644
index 000000000..f382b0c17
--- /dev/null
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationGroupSubmenu.svelte
@@ -0,0 +1,123 @@
+<script lang="ts">
+ import { Check, FolderInput, Plus } from '@lucide/svelte';
+ import { SearchInput } from '$lib/components/app';
+ import * as ContextMenu from '$lib/components/ui/context-menu';
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants';
+ import { MenuKind } from '$lib/enums';
+ import { groupsStore, uiStore } from '$lib/stores';
+ import { tick } from 'svelte';
+
+ interface Props {
+ /** Group the chat already belongs to, ticked in the list. */
+ currentGroupId?: string | null;
+ /** The menu the submenu sits in. */
+ kind?: MenuKind;
+ label: string;
+ /** Closes the parent menu after a pick. */
+ onClose?: () => void;
+ /** Called with the picked group, or with the one created from here. */
+ onSelect: (groupId: string) => void;
+ }
+
+ let {
+ currentGroupId = null,
+ kind = MenuKind.DROPDOWN,
+ label,
+ onClose,
+ onSelect
+ }: Props = $props();
+
+ let Menu = $derived(kind === MenuKind.CONTEXT ? ContextMenu : DropdownMenu);
+
+ let query = $state('');
+ let searchInput = $state<HTMLInputElement | null>(null);
+
+ const groups = $derived.by(() => {
+ const needle = query.trim().toLowerCase();
+ const all = [...groupsStore.groups].sort((a, b) => a.name.localeCompare(b.name));
+
+ return needle ? all.filter((group) => group.name.toLowerCase().includes(needle)) : all;
+ });
+
+ function select(groupId: string) {
+ onSelect(groupId);
+ onClose?.();
+ }
+
+ function handleNewGroup() {
+ uiStore.openGroupDialog({ onCreate: (group) => onSelect(group.id) });
+ onClose?.();
+ }
+
+ async function handleOpenChange(open: boolean) {
+ if (!open) {
+ query = '';
+
+ return;
+ }
+
+ await tick();
+ searchInput?.focus({ preventScroll: true });
+ }
+
+ // the menu's own typeahead would swallow the keystrokes typed in the search
+ function handleSearchKeydown(event: KeyboardEvent) {
+ if (event.key !== 'Escape') event.stopPropagation();
+ }
+</script>
+
+<Menu.Sub onOpenChange={(open) => void handleOpenChange(open)}>
+ <Menu.SubTrigger class="flex cursor-pointer items-center gap-2">
+ <FolderInput class={ICON_CLASS_DEFAULT} />
+
+ <span class="flex-1 whitespace-nowrap">{label}</span>
+ </Menu.SubTrigger>
+
+ <Menu.SubContent
+ alignOffset={-6}
+ class="w-64 p-1.5"
+ onOpenAutoFocus={(event) => event.preventDefault()}
+ sideOffset={6}
+ >
+ <SearchInput
+ bind:ref={searchInput}
+ bind:value={query}
+ onKeyDown={handleSearchKeydown}
+ placeholder="Search groups"
+ size="sm"
+ />
+
+ <div aria-hidden="true" class="mx-1 my-1.5 h-px bg-border/40"></div>
+
+ <button
+ class="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent"
+ onclick={handleNewGroup}
+ type="button"
+ >
+ <Plus class={ICON_CLASS_DEFAULT} />
+
+ <span>New group</span>
+ </button>
+
+ {#if groups.length > 0}
+ <div aria-hidden="true" class="mx-1 my-1.5 h-px bg-border/40"></div>
+
+ <div class="flex max-h-64 flex-col gap-0.5 overflow-y-auto">
+ {#each groups as group (group.id)}
+ <button
+ class="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent"
+ onclick={() => select(group.id)}
+ type="button"
+ >
+ <span class="min-w-0 flex-1 truncate">{group.name}</span>
+
+ {#if group.id === currentGroupId}
+ <Check class="h-4 w-4 shrink-0 text-primary" />
+ {/if}
+ </button>
+ {/each}
+ </div>
+ {/if}
+ </Menu.SubContent>
+</Menu.Sub>
diff --git a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatImportExportTab.svelte b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatImportExportTab.svelte
index 33f796aeb..5bdb18c5a 100644
--- a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatImportExportTab.svelte
+++ b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatImportExportTab.svelte
@@ -9,7 +9,7 @@
import SettingsGroup from '$lib/components/app/settings/SettingsGroup.svelte';
import { ConversationSelectionMode, FileExtensionText, HtmlInputType } from '$lib/enums';
import { ConversationTransferService } from '$lib/services';
- import { conversationsStore, settingsStore } from '$lib/stores';
+ import { conversationsStore, groupsStore, settingsStore } from '$lib/stores';
import { createMessageCountMap } from '$lib/utils';
import { fade } from 'svelte/transition';
import { toast } from 'svelte-sonner';
@@ -207,6 +207,9 @@
.filter((item) => selectedIds.has(item.conv.id));
const { imported, skipped } = await conversationsStore.importConversationsData(selectedData);
+ // an import can add the groups its chats were in
+ await groupsStore.reload();
+
// A conversation already in the database is left untouched, so the summary
// lists what was written and the toast accounts for the rest.
if (skipped.length > 0) {
diff --git a/tools/ui/src/lib/constants/database.constants.ts b/tools/ui/src/lib/constants/database.constants.ts
index f2c961039..fb77d117a 100644
--- a/tools/ui/src/lib/constants/database.constants.ts
+++ b/tools/ui/src/lib/constants/database.constants.ts
@@ -13,17 +13,29 @@ export const DB_NAME = STORAGE_APP_NAME;
/** IndexedDB store / table names */
export const IDXDB_TABLES = {
conversations: 'conversations',
+ groups: 'groups',
messages: 'messages'
} as const;
/** IndexedDB store schemas */
export const IDXDB_STORE_SCHEMAS = {
conversations: 'id, lastModified, currNode, name',
+ groups: 'id, updatedAt, name',
messages: 'id, convId, type, role, timestamp, parent, children'
} as const;
/** Combined Dexie stores definition — keys are table names, values are schemas */
export const IDXDB_STORES = {
[IDXDB_TABLES.conversations]: IDXDB_STORE_SCHEMAS.conversations,
+ [IDXDB_TABLES.groups]: IDXDB_STORE_SCHEMAS.groups,
[IDXDB_TABLES.messages]: IDXDB_STORE_SCHEMAS.messages
} as const;
+
+/** Stores of schema version 1, before the groups table existed. */
+export const IDXDB_STORES_V1 = {
+ [IDXDB_TABLES.conversations]: IDXDB_STORE_SCHEMAS.conversations,
+ [IDXDB_TABLES.messages]: IDXDB_STORE_SCHEMAS.messages
+} as const;
+
+/** Schema version new Dexie instances open the database at; bump with a new version block. */
+export const IDXDB_VERSION = 2;
diff --git a/tools/ui/src/lib/constants/groups.constants.ts b/tools/ui/src/lib/constants/groups.constants.ts
new file mode 100644
index 000000000..0d0221832
--- /dev/null
+++ b/tools/ui/src/lib/constants/groups.constants.ts
@@ -0,0 +1,2 @@
+/** Longest group name, also the name field's `maxlength`. */
+export const GROUP_NAME_MAX_LENGTH = 200;
diff --git a/tools/ui/src/lib/constants/index.ts b/tools/ui/src/lib/constants/index.ts
index fc6a6c83b..9d909018c 100644
--- a/tools/ui/src/lib/constants/index.ts
+++ b/tools/ui/src/lib/constants/index.ts
@@ -29,6 +29,7 @@ export * from './context-keys.constants';
export * from './control-actions.constants';
export * from './css-classes.constants';
export * from './formatters.constants';
+export * from './groups.constants';
export * from './headers.constants';
export * from './key-value-pairs.constants';
export * from './keyboard-shortcuts.constants';
diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts
index 14bb7e400..19ed01c9c 100644
--- a/tools/ui/src/lib/constants/ui.constants.ts
+++ b/tools/ui/src/lib/constants/ui.constants.ts
@@ -45,6 +45,8 @@ export const SIDEBAR_SHOW_MORE_ROW_CLASS = 'sidebar-show-more sticky bottom-0 z-
/** Ids of the sidebar's conversation sections, for their folded state. */
export const SIDEBAR_SECTION_IDS = {
+ /** Followed by the group id: one section per group. */
+ GROUP_PREFIX: 'group:',
PINNED: 'pinned',
RECENT: 'recent'
} as const;
diff --git a/tools/ui/src/lib/enums/ui.enums.ts b/tools/ui/src/lib/enums/ui.enums.ts
index 5f4e5d06a..c4c0156cc 100644
--- a/tools/ui/src/lib/enums/ui.enums.ts
+++ b/tools/ui/src/lib/enums/ui.enums.ts
@@ -32,6 +32,7 @@ export enum SidebarAction {
/** Kind of a sidebar conversation section, which can be dragged into another order. */
export enum SidebarSectionKind {
+ GROUP = 'group',
PINNED = 'pinned',
RECENT = 'recent'
}
diff --git a/tools/ui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts b/tools/ui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts
index 506ce75f4..85adcaeb2 100644
--- a/tools/ui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts
@@ -42,7 +42,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
) {
event.preventDefault();
- void conversationsStore.openNewChat();
+ void conversationsStore.openNewChat({ inNewTab: true });
}
if (
diff --git a/tools/ui/src/lib/services/conversation-transfer.service.ts b/tools/ui/src/lib/services/conversation-transfer.service.ts
index 40a09477a..4d4552d52 100644
--- a/tools/ui/src/lib/services/conversation-transfer.service.ts
+++ b/tools/ui/src/lib/services/conversation-transfer.service.ts
@@ -175,12 +175,12 @@ export class ConversationTransferService {
const record = JSON.parse(trimmed);
if (record.type === SessionRecordType.SESSION) {
- // Drop the discriminator and harness marker; the rest is the conversation.
- const conv = { ...record };
+ // Drop the discriminator, harness marker and group; the rest is the conversation.
+ const { group, ...conv } = record;
delete conv.type;
delete conv.harness;
- current = { conv: conv as DatabaseConversation, messages: [] };
+ current = { conv: conv as DatabaseConversation, messages: [], ...(group ? { group } : {}) };
sessions.push(current);
} else if (record.type === SessionRecordType.MESSAGE) {
if (!current) {
@@ -210,11 +210,12 @@ export class ConversationTransferService {
* @returns The JSONL string (one record per line)
*/
static serializeSessionToJsonl(data: ExportedConversation): string {
- const { conv, messages } = data;
+ const { conv, group, messages } = data;
const sessionLine = JSON.stringify({
harness: EXPORT_CONV.HARNESS,
type: SessionRecordType.SESSION,
- ...conv
+ ...conv,
+ ...(group ? { group } : {})
});
const messageLines = messages.map((message: DatabaseMessage) => {
// `toolCalls` is stored as a JSON string; drop it when empty, otherwise parse it.
diff --git a/tools/ui/src/lib/services/database.service.ts b/tools/ui/src/lib/services/database.service.ts
index a466f8483..af9fad9a2 100644
--- a/tools/ui/src/lib/services/database.service.ts
+++ b/tools/ui/src/lib/services/database.service.ts
@@ -1,26 +1,37 @@
/**
- * DatabaseService - IndexedDB persistence for conversations and messages
+ * DatabaseService - IndexedDB persistence for conversations, messages and groups
*
- * Thin Dexie layer over the conversations/messages tables: CRUD, tree
+ * Thin Dexie layer over the conversations/messages/groups tables: CRUD, tree
* navigation (descendants, reparenting) and cascading deletes. No reactive
- * state; consumed by conversationsStore and the chat flows.
+ * state; consumed by conversationsStore, groupsStore and the chat flows.
*/
-import { IDXDB_STORES, IDXDB_TABLES, STORAGE_APP_NAME } from '$lib/constants';
+import {
+ IDXDB_STORE_SCHEMAS,
+ IDXDB_STORES_V1,
+ IDXDB_TABLES,
+ IDXDB_VERSION,
+ STORAGE_APP_NAME
+} from '$lib/constants';
import { MessageRole } from '$lib/enums';
import type { McpServerOverride } from '$lib/types/database';
import type { ExportedConversation } from '$lib/types/database';
+import type { Group } from '$lib/types/groups';
import { filterByLeafNodeId, findDescendantMessages, uuid } from '$lib/utils';
import Dexie, { type EntityTable } from 'dexie';
class LlamaUiDatabase extends Dexie {
[IDXDB_TABLES.conversations]!: EntityTable<DatabaseConversation, string>;
+ [IDXDB_TABLES.groups]!: EntityTable<Group, 'id'>;
[IDXDB_TABLES.messages]!: EntityTable<DatabaseMessage, string>;
constructor() {
super(STORAGE_APP_NAME);
- this.version(1).stores(IDXDB_STORES);
+ this.version(1).stores(IDXDB_STORES_V1);
+ this.version(IDXDB_VERSION).stores({
+ [IDXDB_TABLES.groups]: IDXDB_STORE_SCHEMAS.groups
+ });
}
}
@@ -146,6 +157,17 @@ export class DatabaseService {
return conversation;
}
+ /**
+ * Creates a new group.
+ *
+ * @returns The created group
+ */
+ static async createGroup(group: Group): Promise<Group> {
+ await db[IDXDB_TABLES.groups].add(group);
+
+ return group;
+ }
+
/**
* Creates a new message branch by adding a message and updating parent/child relationships.
* Also updates the conversation's currNode to point to the new message.
@@ -309,6 +331,26 @@ export class DatabaseService {
);
}
+ /**
+ * Deletes a group. Its conversations stay, out of any group.
+ *
+ * @param id - Group ID
+ */
+ static async deleteGroup(id: string): Promise<void> {
+ await db.transaction(
+ 'rw',
+ [db[IDXDB_TABLES.groups], db[IDXDB_TABLES.conversations]],
+ async () => {
+ await db[IDXDB_TABLES.groups].delete(id);
+ await db[IDXDB_TABLES.conversations]
+ .filter((conversation) => conversation.groupId === id)
+ .modify((conversation) => {
+ delete conversation.groupId;
+ });
+ }
+ );
+ }
+
/**
* Deletes a message and removes it from its parent's children array.
*
@@ -422,6 +464,7 @@ export class DatabaseService {
currNode: lastClonedMessage.id,
cwd: sourceConv.cwd,
forkedFromConversationId: sourceConvId,
+ groupId: sourceConv.groupId,
id: newConvId,
lastModified: Date.now(),
mcpServerOverrides: sourceConv.mcpServerOverrides
@@ -450,6 +493,18 @@ export class DatabaseService {
return await db[IDXDB_TABLES.conversations].orderBy('lastModified').reverse().toArray();
}
+ /**
+ * Gets all groups, newest first by creation, the order new ones join the
+ * loaded list in, so a rename does not move a group on the next load.
+ *
+ * @returns Array of groups
+ */
+ static async getAllGroups(): Promise<Group[]> {
+ const groups = await db[IDXDB_TABLES.groups].toArray();
+
+ return groups.sort((a, b) => b.createdAt - a.createdAt);
+ }
+
/**
* Gets a conversation by ID.
*
@@ -521,25 +576,31 @@ export class DatabaseService {
* @returns The conversations written to the database and the ones skipped
*/
static async importConversations(
- data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
+ data: ExportedConversation[]
): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> {
const imported: DatabaseConversation[] = [];
const skipped: DatabaseConversation[] = [];
return await db.transaction(
'rw',
- [db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
+ [db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.groups], db[IDXDB_TABLES.messages]],
async () => {
for (const item of data) {
- const { conv, messages } = item;
- const existing = await db[IDXDB_TABLES.conversations].get(conv.id);
+ const { messages } = item;
+ const existing = await db[IDXDB_TABLES.conversations].get(item.conv.id);
if (existing) {
- skipped.push(conv);
+ skipped.push(item.conv);
continue;
}
+ const conv = { ...item.conv };
+ const groupId = await DatabaseService.importGroup(item);
+
+ if (groupId) conv.groupId = groupId;
+ else delete conv.groupId;
+
await db[IDXDB_TABLES.conversations].add(conv);
for (const msg of messages) {
await db[IDXDB_TABLES.messages].put(msg);
@@ -553,6 +614,28 @@ export class DatabaseService {
);
}
+ /**
+ * Puts conversations into a group, or out of any group when `groupId` is null.
+ * A conversation that joins a group is unpinned.
+ *
+ * @param ids - Conversation IDs
+ * @param groupId - Target group, null to take them out of their group
+ */
+ static async setConversationsGroup(ids: string[], groupId: string | null): Promise<void> {
+ await db[IDXDB_TABLES.conversations]
+ .where('id')
+ .anyOf(ids)
+ .modify((conversation) => {
+ if (groupId) {
+ conversation.groupId = groupId;
+
+ if (conversation.pinned) conversation.pinned = false;
+ } else {
+ delete conversation.groupId;
+ }
+ });
+ }
+
/**
* Toggles the pinned status of a conversation.
*
@@ -601,6 +684,17 @@ export class DatabaseService {
});
}
+ /**
+ * Updates a group. `updatedAt` is never stamped implicitly; pass it in
+ * `updates` to bump the group in recency ordering.
+ *
+ * @param id - Group ID
+ * @param updates - Partial group data to update
+ */
+ static async updateGroup(id: string, updates: Partial<Omit<Group, 'id'>>): Promise<void> {
+ await db[IDXDB_TABLES.groups].update(id, updates);
+ }
+
/**
* Updates a message.
*
@@ -628,6 +722,38 @@ export class DatabaseService {
});
}
+ /**
+ * Finds the group an imported conversation goes into: the group of the same
+ * id, else one of the same name, else a new one named after the file's. A
+ * conversation whose group the file does not carry keeps it only if it exists.
+ * Runs inside the import transaction.
+ *
+ * @returns The group id, or undefined for none
+ */
+ private static async importGroup(item: ExportedConversation): Promise<string | undefined> {
+ const groups = db[IDXDB_TABLES.groups];
+
+ if (!item.group) {
+ const { groupId } = item.conv;
+
+ return groupId && (await groups.get(groupId)) ? groupId : undefined;
+ }
+
+ const { id, name } = item.group;
+
+ if (await groups.get(id)) return id;
+
+ const sameName = await groups.where('name').equals(name).first();
+
+ if (sameName) return sameName.id;
+
+ const now = Date.now();
+
+ await groups.add({ createdAt: now, id, name, updatedAt: now });
+
+ return id;
+ }
+
/**
* Removes a child id from its parent message's children array.
*/
diff --git a/tools/ui/src/lib/services/migration.service.ts b/tools/ui/src/lib/services/migration.service.ts
index d52d67d7d..6df50f45b 100644
--- a/tools/ui/src/lib/services/migration.service.ts
+++ b/tools/ui/src/lib/services/migration.service.ts
@@ -13,7 +13,9 @@ import {
DB_APP_NAME_DEPRECATED,
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
IDXDB_STORES,
+ IDXDB_STORES_V1,
IDXDB_TABLES,
+ IDXDB_VERSION,
LEGACY_AGENTIC_REGEX,
LEGACY_REASONING_TAGS,
NEW_TO_DEPRECATED_MAP,
@@ -166,7 +168,7 @@ const idxdbMigration: Migration = {
// Check if new database already has data
const newDb = new Dexie(STORAGE_APP_NAME);
- newDb.version(1).stores(IDXDB_STORES);
+ newDb.version(IDXDB_VERSION).stores(IDXDB_STORES);
const existingConvs = await newDb.table(IDXDB_TABLES.conversations).count();
if (existingConvs > 0) {
@@ -181,7 +183,7 @@ const idxdbMigration: Migration = {
const oldDb = new Dexie(DB_APP_NAME_DEPRECATED);
- oldDb.version(1).stores(IDXDB_STORES);
+ oldDb.version(1).stores(IDXDB_STORES_V1);
const conversations = await oldDb.table(IDXDB_TABLES.conversations).toArray();
const messages = await oldDb.table(IDXDB_TABLES.messages).toArray();
diff --git a/tools/ui/src/lib/stores/conversations/index.svelte.ts b/tools/ui/src/lib/stores/conversations/index.svelte.ts
index 22b4df0b6..e338ffcc3 100644
--- a/tools/ui/src/lib/stores/conversations/index.svelte.ts
+++ b/tools/ui/src/lib/stores/conversations/index.svelte.ts
@@ -9,7 +9,7 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
-import { ROUTES } from '$lib/constants';
+import { NEW_CHAT_TAB_ID, ROUTES } from '$lib/constants';
import { MessageRole } from '$lib/enums';
import { ConversationTransferService } from '$lib/services/conversation-transfer.service';
import { DatabaseService } from '$lib/services/database.service';
@@ -23,7 +23,12 @@ import {
} from '$lib/stores/conversations/preferences.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import { tabsStore } from '$lib/stores/tabs.svelte';
-import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
+import {
+ collectForkFamily,
+ filterByLeafNodeId,
+ findLeafNode,
+ generateConversationTitle
+} from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
@@ -118,6 +123,23 @@ class ConversationsStore implements ConversationsPreferencesHost {
);
}
+ /**
+ * Puts the active conversation into a group, or out of one with null.
+ * With no active conversation the pick is buffered and the next created
+ * chat joins that group.
+ *
+ * @param groupId - Target group, null to leave the current one
+ */
+ async assignActiveConversationGroup(groupId: string | null): Promise<void> {
+ const active = this.activeConversation;
+
+ if (active) {
+ await this.setConversationGroup(active.id, groupId);
+ } else {
+ this.preferences.pendingGroupId = groupId;
+ }
+ }
+
/**
* Deletes multiple conversations in sequence.
* Mirrors deleteConversation() per-id; navigates to the new-chat screen only
@@ -276,11 +298,13 @@ class ConversationsStore implements ConversationsPreferencesHost {
// new chats.
const conversation = await DatabaseService.createConversation(conversationName, {
cwd: this.preferences.pendingCwd ?? undefined,
+ groupId: this.preferences.pendingGroupId ?? undefined,
reasoningEffort: this.preferences.pendingReasoningEffort,
...this.preferences.getToolPolicySnapshot()
});
this.preferences.pendingCwd = null;
+ this.preferences.pendingGroupId = null;
this.conversations = [conversation, ...this.conversations];
this.activeConversation = conversation;
@@ -481,11 +505,20 @@ class ConversationsStore implements ConversationsPreferencesHost {
* @returns List of conversations with messages, ordered by the input IDs
*/
async getConversationsForExport(convIds: string[]): Promise<ExportedConversation[]> {
- const fetched = await DatabaseService.getConversationsWithMessages(convIds);
+ const [fetched, groups] = await Promise.all([
+ DatabaseService.getConversationsWithMessages(convIds),
+ DatabaseService.getAllGroups()
+ ]);
+ const groupsById = new Map(groups.map((group) => [group.id, group]));
return convIds
.map((id) => fetched.get(id))
- .filter((entry): entry is ExportedConversation => entry !== undefined);
+ .filter((entry): entry is ExportedConversation => entry !== undefined)
+ .map((entry) => {
+ const group = entry.conv.groupId ? groupsById.get(entry.conv.groupId) : undefined;
+
+ return group ? { ...entry, group: { id: group.id, name: group.name } } : entry;
+ });
}
/**
@@ -617,11 +650,17 @@ class ConversationsStore implements ConversationsPreferencesHost {
}
/**
- * Start a fresh chat by navigating to the bare `#/` new-chat screen. The
- * chat layout opens a new-chat tab for it when Conversation tabs are on.
+ * Start a fresh chat by navigating to the bare `#/` new-chat screen.
+ *
+ * @param options.inNewTab - With Conversation tabs on, open it in a tab of
+ * its own, like the tab bar's "+", so the open chat keeps its tab
*/
- async openNewChat(): Promise<void> {
+ async openNewChat({ inNewTab = false } = {}): Promise<void> {
+ if (inNewTab && settingsStore.config.conversationTabs)
+ await tabsStore.openInNewTab(NEW_CHAT_TAB_ID);
+
this.clearActiveConversation();
+ this.preferences.pendingGroupId = null;
await goto(ROUTES.START);
}
@@ -658,6 +697,33 @@ class ConversationsStore implements ConversationsPreferencesHost {
return undefined;
}
+ /**
+ * Moves a conversation with its whole fork family into a group, or out of one
+ * when `groupId` is null: a family never splits across sections. A pinned chat
+ * that joins a group leaves Pinned, since group sections do not show pinned chats.
+ *
+ * @param convId - Conversation to move
+ * @param groupId - Target group, null to take the conversations out of their group
+ */
+ async setConversationGroup(convId: string, groupId: string | null): Promise<void> {
+ const ids = new Set(collectForkFamily(this.conversations, convId));
+
+ try {
+ await DatabaseService.setConversationsGroup([...ids], groupId);
+
+ for (const conversation of this.conversations) {
+ if (!ids.has(conversation.id)) continue;
+
+ this.applyConversationUpdate(conversation.id, {
+ groupId: groupId ?? undefined,
+ ...(groupId && conversation.pinned ? { pinned: false } : {})
+ });
+ }
+ } catch (error) {
+ console.error('Failed to move conversation to group:', error);
+ }
+ }
+
/**
* Removes messages from active messages starting at an index
*/
diff --git a/tools/ui/src/lib/stores/conversations/preferences.svelte.ts b/tools/ui/src/lib/stores/conversations/preferences.svelte.ts
index 916911a14..29d7c45ca 100644
--- a/tools/ui/src/lib/stores/conversations/preferences.svelte.ts
+++ b/tools/ui/src/lib/stores/conversations/preferences.svelte.ts
@@ -80,6 +80,14 @@ export class ConversationPreferences {
*/
pendingCwd = $state<string | null>(null);
+ /**
+ * Group the chat about to start goes into, picked before any conversation
+ * exists. Consumed by `createConversation()` so the first send lands in
+ * that group. Unlike `pendingCwd` it outlives `resetPending()`, so leaving
+ * the new-chat tab and coming back keeps it; `openNewChat()` clears it.
+ */
+ pendingGroupId = $state<string | null>(null);
+
/** Global (non-conversation-specific) reasoning effort default */
pendingReasoningEffort = $state<ReasoningEffort>(loadReasoningEffortDefault());
diff --git a/tools/ui/src/lib/stores/groups.svelte.ts b/tools/ui/src/lib/stores/groups.svelte.ts
new file mode 100644
index 000000000..de0ff0dec
--- /dev/null
+++ b/tools/ui/src/lib/stores/groups.svelte.ts
@@ -0,0 +1,148 @@
+/**
+ * groupsStore - Named sidebar sections the user sorts chats into
+ *
+ * Mirrors the row-per-group table through DatabaseService, like
+ * conversationsStore: every mutation awaits the row write, then applies the
+ * change to the loaded list.
+ */
+
+import { browser } from '$app/environment';
+import { GROUP_NAME_MAX_LENGTH, SIDEBAR_SECTION_IDS } from '$lib/constants';
+import { DatabaseService } from '$lib/services/database.service';
+// direct imports between stores, not via the barrel, to avoid circular deps
+import { conversationsStore } from '$lib/stores/conversations/index.svelte';
+import { uiStore } from '$lib/stores/ui.svelte';
+import type { DatabaseConversation, Group } from '$lib/types';
+import { uuid } from '$lib/utils';
+
+function normalizeName(name: string): string {
+ return name.trim().slice(0, GROUP_NAME_MAX_LENGTH);
+}
+
+class GroupsStore {
+ /** All groups, newest first. */
+ groups = $state<Group[]>([]);
+
+ /** Whether the list was read from the database at least once. */
+ isInitialized = $state(false);
+
+ private byId = $derived(new Map(this.groups.map((group) => [group.id, group])));
+
+ private initPromise: Promise<void> | null = null;
+
+ /**
+ * Creates a group and puts it at the top of the list.
+ *
+ * @param name - The group's name
+ * @returns The created group, or undefined when the name is empty
+ */
+ async create(name: string): Promise<Group | undefined> {
+ const trimmed = normalizeName(name);
+
+ if (!trimmed) return undefined;
+
+ const now = Date.now();
+ const group: Group = { createdAt: now, id: uuid(), name: trimmed, updatedAt: now };
+
+ try {
+ await DatabaseService.createGroup(group);
+ } catch (error) {
+ console.error('Failed to create group:', error);
+
+ return undefined;
+ }
+
+ this.groups = [group, ...this.groups];
+
+ return group;
+ }
+
+ get(id: string): Group | undefined {
+ return this.byId.get(id);
+ }
+
+ /** The group a conversation is in, null when it is in none or its group is gone. */
+ groupIdOf(conversation: Pick<DatabaseConversation, 'groupId'>): string | null {
+ const { groupId } = conversation;
+
+ return groupId && this.byId.has(groupId) ? groupId : null;
+ }
+
+ /**
+ * Reads every group from the database.
+ * Safe to call multiple times: concurrent callers share a single run,
+ * and a failed run can be retried by calling again.
+ */
+ initialize(): Promise<void> {
+ if (!browser) return Promise.resolve();
+
+ if (this.initPromise) return this.initPromise;
+
+ this.initPromise = (async () => {
+ try {
+ await this.reload();
+ this.isInitialized = true;
+ } catch (error) {
+ console.error('Failed to load groups:', error);
+ this.initPromise = null;
+ }
+ })();
+
+ return this.initPromise;
+ }
+
+ /** Reads the groups again, e.g. after an import added some. */
+ async reload(): Promise<void> {
+ this.groups = await DatabaseService.getAllGroups();
+ }
+
+ /**
+ * Deletes a group. Its chats stay, out of any group.
+ *
+ * @param id - Group ID
+ */
+ async remove(id: string): Promise<void> {
+ try {
+ await DatabaseService.deleteGroup(id);
+ } catch (error) {
+ console.error('Failed to delete group:', error);
+
+ return;
+ }
+
+ this.groups = this.groups.filter((group) => group.id !== id);
+ uiStore.forgetSidebarSection(`${SIDEBAR_SECTION_IDS.GROUP_PREFIX}${id}`);
+
+ for (const conversation of conversationsStore.conversations) {
+ if (conversation.groupId === id) {
+ conversationsStore.applyConversationUpdate(conversation.id, { groupId: undefined });
+ }
+ }
+ }
+
+ /**
+ * Renames a group. An empty name keeps the current one.
+ *
+ * @param id - Group ID
+ * @param name - The new name
+ */
+ async rename(id: string, name: string): Promise<void> {
+ const trimmed = normalizeName(name);
+
+ if (!trimmed || !this.get(id)) return;
+
+ const changes = { name: trimmed, updatedAt: Date.now() };
+
+ try {
+ await DatabaseService.updateGroup(id, changes);
+ } catch (error) {
+ console.error('Failed to rename group:', error);
+
+ return;
+ }
+
+ this.groups = this.groups.map((group) => (group.id === id ? { ...group, ...changes } : group));
+ }
+}
+
+export const groupsStore = new GroupsStore();
diff --git a/tools/ui/src/lib/stores/index.ts b/tools/ui/src/lib/stores/index.ts
index f1798f1cb..23eb809bd 100644
--- a/tools/ui/src/lib/stores/index.ts
+++ b/tools/ui/src/lib/stores/index.ts
@@ -38,6 +38,9 @@ export { conversationsStore } from './conversations/index.svelte';
// MCP
export { mcpStore } from './mcp/index.svelte';
+// GROUPS (named sidebar sections for chats)
+export { groupsStore } from './groups.svelte';
+
// MODELS
export { modelsStore } from './models/index.svelte';
diff --git a/tools/ui/src/lib/stores/init.ts b/tools/ui/src/lib/stores/init.ts
index 4473349f9..8f836d00b 100644
--- a/tools/ui/src/lib/stores/init.ts
+++ b/tools/ui/src/lib/stores/init.ts
@@ -1,5 +1,6 @@
// direct imports, not via the barrel, to avoid circular deps
import { conversationsStore } from './conversations/index.svelte';
+import { groupsStore } from './groups.svelte';
import { permissionsStore } from './permissions.svelte';
import { settingsStore } from './settings/index.svelte';
import { tabsStore } from './tabs.svelte';
@@ -34,6 +35,7 @@ export function initStores(): Promise<void> {
permissionsStore.initialize();
toolsStore.initialize();
void versionStore.initialize();
+ void groupsStore.initialize();
// the full conversation list loads in the background; once it is back,
// prune persisted tabs against the conversations that still exist
diff --git a/tools/ui/src/lib/stores/ui.svelte.ts b/tools/ui/src/lib/stores/ui.svelte.ts
index 86e33d6f9..6a4d02073 100644
--- a/tools/ui/src/lib/stores/ui.svelte.ts
+++ b/tools/ui/src/lib/stores/ui.svelte.ts
@@ -22,9 +22,23 @@ import {
} from '$lib/constants';
import { deviceStore } from '$lib/stores/device.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
+import type { Group } from '$lib/types/groups';
import { remToPx } from '$lib/utils/css';
import { SvelteSet } from 'svelte/reactivity';
+interface GroupDialogOptions {
+ /** Group to rename; without it the dialog creates a new one. */
+ groupId?: string;
+ /** Called with the new group after the dialog creates one. */
+ onCreate?: (group: Group) => void;
+}
+
+interface GroupPickerOptions {
+ /** Group ticked in the list: the one the chat is in. */
+ currentGroupId?: string | null;
+ onSelect: (groupId: string) => void;
+}
+
/** Sidebar sections the user folded, read once on load. */
function loadCollapsedSidebarSections(): SvelteSet<string> {
if (!browser) return new SvelteSet<string>();
@@ -65,9 +79,16 @@ class UiStore {
/** Open state of the Discover dialog. */
discoverModelsOpen = $state(false);
+
/** Sidebar sections showing all their chats past the preview, for this session. */
expandedSidebarSections = new SvelteSet<string>();
+ /** Open state of the group dialog, `null` while closed. */
+ groupDialog = $state<GroupDialogOptions | null>(null);
+
+ /** Open state of the group picker, a phone's stand-in for the group submenus. */
+ groupPicker = $state<GroupPickerOptions | null>(null);
+
/** Whether the main chat form is expanded into its large editor. */
isComposerExpanded = $state(false);
@@ -134,6 +155,17 @@ class UiStore {
this.modelsSelectorCloseSignal++;
}
+ /** Drop a removed section, e.g. a deleted group's, from the remembered order and folds. */
+ forgetSidebarSection(id: string): void {
+ this.expandedSidebarSections.delete(id);
+
+ if (this.sidebarSectionOrder.includes(id)) {
+ this.setSidebarSectionOrder(this.sidebarSectionOrder.filter((sectionId) => sectionId !== id));
+ }
+
+ if (this.collapsedSidebarSections.has(id)) this.toggleSidebarSection(id);
+ }
+
isSidebarSectionExpanded(id: string): boolean {
return this.expandedSidebarSections.has(id);
}
@@ -142,6 +174,16 @@ class UiStore {
return !this.collapsedSidebarSections.has(id);
}
+ /** Open the group dialog to create a group, or to rename `groupId`. */
+ openGroupDialog(options: GroupDialogOptions = {}): void {
+ this.groupDialog = options;
+ }
+
+ /** Open the group picker, which calls `onSelect` with the picked group. */
+ openGroupPicker(options: GroupPickerOptions): void {
+ this.groupPicker = options;
+ }
+
/** Open the model details drawer over the current page, without leaving it. */
openModelDetails(focus: string): void {
this.modelDetailsFocus = focus;
diff --git a/tools/ui/src/lib/types/database.d.ts b/tools/ui/src/lib/types/database.d.ts
index c2fca591a..b19008bec 100644
--- a/tools/ui/src/lib/types/database.d.ts
+++ b/tools/ui/src/lib/types/database.d.ts
@@ -1,5 +1,6 @@
import { AttachmentType, ReasoningEffort, ToolSource } from '$lib/enums';
import type { ChatMessageTimings, ChatMessageType, ChatRole } from '$lib/types/chat';
+import type { Group } from '$lib/types/groups';
/**
* @deprecated Legacy per-conversation MCP server flags. MCP server enabled
@@ -26,6 +27,8 @@ export interface DatabaseConversation {
/** Tool categories disabled for this conversation */
disabledToolCategories?: ToolSource[];
forkedFromConversationId?: string;
+ /** Sidebar group the conversation is sorted into. */
+ groupId?: string;
pinned?: boolean;
}
@@ -144,6 +147,8 @@ export interface DatabaseMessage {
export type ExportedConversation = {
conv: DatabaseConversation;
+ /** The conversation's group, so an import elsewhere can sort it into one of that name. */
+ group?: Pick<Group, 'id' | 'name'>;
messages: DatabaseMessage[];
};
diff --git a/tools/ui/src/lib/types/groups.d.ts b/tools/ui/src/lib/types/groups.d.ts
new file mode 100644
index 000000000..7821e4aa3
--- /dev/null
+++ b/tools/ui/src/lib/types/groups.d.ts
@@ -0,0 +1,7 @@
+/** A named sidebar section the user sorts chats into. */
+export interface Group {
+ createdAt: number;
+ id: string;
+ name: string;
+ updatedAt: number;
+}
diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts
index 29e331db9..aa8ccc99c 100644
--- a/tools/ui/src/lib/types/index.ts
+++ b/tools/ui/src/lib/types/index.ts
@@ -198,6 +198,9 @@ export type {
MCPServerResources
} from './mcp';
+// Group types
+export type { Group } from './groups';
+
// Search result types
export type { SearchResult } from './search';
diff --git a/tools/ui/src/lib/types/navigation.d.ts b/tools/ui/src/lib/types/navigation.d.ts
index d1c5840c7..4bb1ab7d2 100644
--- a/tools/ui/src/lib/types/navigation.d.ts
+++ b/tools/ui/src/lib/types/navigation.d.ts
@@ -1,5 +1,5 @@
-import type { SidebarAction } from '$lib/enums';
-import type { Component } from 'svelte';
+import type { MenuKind, SidebarAction } from '$lib/enums';
+import type { Component, Snippet } from 'svelte';
/**
* A single clickable action in the desktop sidebar icon strip.
@@ -24,6 +24,11 @@ export interface MenuAction {
icon: Component;
label: string;
onclick: (event: Event) => void;
+ /**
+ * Flyout rendered in place of the row in a desktop menu, through the kit of
+ * the menu it is in; a phone's menus keep the row and its `onclick`.
+ */
+ submenu?: Snippet<[{ close: () => void; kind: MenuKind }]>;
variant?: 'default' | 'destructive';
disabled?: boolean;
/** Shown on hover, e.g. why a disabled action is not available. */
diff --git a/tools/ui/src/lib/utils/conversation-utils.ts b/tools/ui/src/lib/utils/conversation-utils.ts
index 69b2cb072..6fd359f37 100644
--- a/tools/ui/src/lib/utils/conversation-utils.ts
+++ b/tools/ui/src/lib/utils/conversation-utils.ts
@@ -59,6 +59,36 @@ const comparePinnedThenRecent = (a: DatabaseConversation, b: DatabaseConversatio
return b.lastModified - a.lastModified;
};
+/**
+ * Collects the whole fork family of a conversation: the original it was forked
+ * from, up the chain, and every fork below that original, at any depth.
+ *
+ * @param convs - Conversations to search
+ * @param convId - Any conversation of the family
+ * @returns IDs of every conversation in the family, the original first
+ */
+export function collectForkFamily(convs: DatabaseConversation[], convId: string): string[] {
+ const byId = new Map(convs.map((conv) => [conv.id, conv]));
+
+ let rootId = convId;
+ let parentId = byId.get(convId)?.forkedFromConversationId;
+
+ while (parentId && byId.has(parentId)) {
+ rootId = parentId;
+ parentId = byId.get(parentId)?.forkedFromConversationId;
+ }
+
+ const ids = [rootId];
+
+ for (let i = 0; i < ids.length; i++) {
+ for (const conv of convs) {
+ if (conv.forkedFromConversationId === ids[i]) ids.push(conv.id);
+ }
+ }
+
+ return ids;
+}
+
/**
* Builds a flat tree of conversations with depth levels for nested forks.
* Accepts a pre-filtered list so search filtering stays in the component.
diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
index b51531fad..6fcc9229b 100644
--- a/tools/ui/src/lib/utils/index.ts
+++ b/tools/ui/src/lib/utils/index.ts
@@ -67,6 +67,7 @@ export {
getMessageCount,
getConversationModel,
buildConversationTree,
+ collectForkFamily,
type ConversationTreeItem
} from './conversation-utils';
diff --git a/tools/ui/tests/client/groups-db.svelte.test.ts b/tools/ui/tests/client/groups-db.svelte.test.ts
new file mode 100644
index 000000000..d9c636bc9
--- /dev/null
+++ b/tools/ui/tests/client/groups-db.svelte.test.ts
@@ -0,0 +1,98 @@
+import { DatabaseService } from '$lib/services/database.service';
+import type { Group } from '$lib/types';
+import { afterEach, describe, expect, it } from 'vitest';
+
+function makeGroup(id: string): Group {
+ return { createdAt: 0, id, name: `Group ${id}`, updatedAt: 0 };
+}
+
+afterEach(async () => {
+ const groups = await DatabaseService.getAllGroups();
+
+ await Promise.all(groups.map((group) => DatabaseService.deleteGroup(group.id)));
+
+ const conversations = await DatabaseService.getAllConversations();
+
+ await Promise.all(
+ conversations.map((conversation) => DatabaseService.deleteConversation(conversation.id))
+ );
+});
+
+/**
+ * Groups live in their own table; deleting one must leave its chats in place,
+ * out of any group.
+ */
+describe('DatabaseService groups', () => {
+ it('round-trips a group', async () => {
+ await DatabaseService.createGroup(makeGroup('a'));
+
+ expect(await DatabaseService.getAllGroups()).toEqual([makeGroup('a')]);
+ });
+
+ it('applies partial updates without dropping other fields', async () => {
+ await DatabaseService.createGroup(makeGroup('a'));
+
+ await DatabaseService.updateGroup('a', { name: 'Renamed', updatedAt: 5 });
+
+ expect(await DatabaseService.getAllGroups()).toEqual([
+ { ...makeGroup('a'), name: 'Renamed', updatedAt: 5 }
+ ]);
+ });
+
+ it('returns groups newest first by creation, whatever their updates', async () => {
+ await DatabaseService.createGroup({ ...makeGroup('a'), createdAt: 1, updatedAt: 9 });
+ await DatabaseService.createGroup({ ...makeGroup('b'), createdAt: 2, updatedAt: 3 });
+
+ expect((await DatabaseService.getAllGroups()).map((group) => group.id)).toEqual(['b', 'a']);
+ });
+
+ it('takes the chats out of a deleted group and keeps them', async () => {
+ await DatabaseService.createGroup(makeGroup('a'));
+ const inGroup = await DatabaseService.createConversation('in', { groupId: 'a' });
+ const elsewhere = await DatabaseService.createConversation('out', { groupId: 'b' });
+
+ await DatabaseService.deleteGroup('a');
+
+ expect(await DatabaseService.getAllGroups()).toEqual([]);
+ expect((await DatabaseService.getConversation(inGroup.id))?.groupId).toBeUndefined();
+ expect((await DatabaseService.getConversation(elsewhere.id))?.groupId).toBe('b');
+ });
+
+ it("sorts imported chats into the file's group, by id, then by name, else a new one", async () => {
+ await DatabaseService.createGroup(makeGroup('a'));
+ await DatabaseService.createGroup({ ...makeGroup('b'), name: 'Research' });
+
+ const chat = (id: string, groupId?: string) => ({
+ conv: { currNode: '', groupId, id, lastModified: 0, name: id },
+ messages: []
+ });
+
+ await DatabaseService.importConversations([
+ { ...chat('same-id', 'a'), group: { id: 'a', name: 'Other name' } },
+ { ...chat('same-name', 'x'), group: { id: 'x', name: 'Research' } },
+ { ...chat('new-group', 'y'), group: { id: 'y', name: 'Travel' } },
+ chat('dangling', 'gone')
+ ]);
+
+ const groupOf = async (id: string) => (await DatabaseService.getConversation(id))?.groupId;
+
+ expect(await groupOf('same-id')).toBe('a');
+ expect(await groupOf('same-name')).toBe('b');
+ expect(await groupOf('new-group')).toBe('y');
+ expect(await groupOf('dangling')).toBeUndefined();
+ expect((await DatabaseService.getAllGroups()).map((group) => group.name)).toContain('Travel');
+ });
+
+ it('keeps a forked chat in the group of the chat it was forked from', async () => {
+ await DatabaseService.createGroup(makeGroup('a'));
+ const source = await DatabaseService.createConversation('source', { groupId: 'a' });
+ const rootId = await DatabaseService.createRootMessage(source.id);
+ const fork = await DatabaseService.forkConversation(source.id, rootId, {
+ includeAttachments: false,
+ name: 'fork'
+ });
+
+ expect(fork.groupId).toBe('a');
+ expect((await DatabaseService.getConversation(fork.id))?.groupId).toBe('a');
+ });
+});
diff --git a/tools/ui/tests/unit/conversation-export.test.ts b/tools/ui/tests/unit/conversation-export.test.ts
index e3ca01c31..102899d85 100644
--- a/tools/ui/tests/unit/conversation-export.test.ts
+++ b/tools/ui/tests/unit/conversation-export.test.ts
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('$lib/services/database.service', () => ({
- DatabaseService: { getConversationsWithMessages: vi.fn() }
+ DatabaseService: { getAllGroups: vi.fn(async () => []), getConversationsWithMessages: vi.fn() }
}));
import { MessageRole, MessageType } from '$lib/enums';
diff --git a/tools/ui/tests/unit/conversation-import.test.ts b/tools/ui/tests/unit/conversation-import.test.ts
index d89189c5f..ac5dc9a3f 100644
--- a/tools/ui/tests/unit/conversation-import.test.ts
+++ b/tools/ui/tests/unit/conversation-import.test.ts
@@ -38,6 +38,20 @@ describe('ConversationTransferService.parseImportFile', () => {
expect(sessions[0].messages[0].content).toBe('hello from a');
});
+ it('keeps the session group apart from the conversation through a JSONL round trip', () => {
+ const session = { ...makeSession('a'), group: { id: 'g1', name: 'Research' } };
+
+ session.conv.groupId = 'g1';
+
+ const [parsed] = ConversationTransferService.parseSessionsJsonl(
+ ConversationTransferService.serializeSessionToJsonl(session)
+ );
+
+ expect(parsed.group).toEqual({ id: 'g1', name: 'Research' });
+ expect(parsed.conv).not.toHaveProperty('group');
+ expect(parsed.conv.groupId).toBe('g1');
+ });
+
it('imports several sessions from one JSONL file', async () => {
const jsonl = [makeSession('a'), makeSession('b')]
.map((session) => ConversationTransferService.serializeSessionToJsonl(session))