Commit c6a0ff11a for llama.cpp
commit c6a0ff11a0fe16ac1a2eb569190934593a2b3670
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date: Wed Nov 4 18:40:55 2026 +0100
ui: open actions on right-click for chats, tabs, messages and models (#31545)
* ui : share directory resolution and the cwd row
Move the lookup of a browser-picked folder to ToolsService.resolveDirectoryPath, let the working directory picker run without its chip, and split the 'Set working directory to' row into its own component, so other views can pick and show a folder the same way.
Assisted by: Claude (Opus 5.5 Medium)
* ui : open an item's actions on right-click
Sidebar conversations and models manager rows open the same actions as
their "more actions" menu on right-click. Both menus render one shared
list of actions, so they cannot drift apart. Phones keep the browser's
long-press; the dropdown's drawer already holds the actions there.
An action can render a flyout submenu in place of its row, through the
kit of the menu it is in, and close the menu after a pick.
Adds the shadcn-svelte context-menu component, mirroring the
dropdown-menu one.
Assisted by: Claude (Opus 5.5 Medium)
* ui : close conversation tabs from a right-click menu
A tab's right-click menu closes it, the other tabs or the tabs to its
right, and stops a response the tab is generating. Closing tabs that
include the active one keeps the tab the menu was opened on.
Assisted by: Claude (Opus 5.5 Medium)
* ui : open a message's actions on right-click
User and assistant messages open the same actions as their action icons
on right-click. Selected text, links, media, code and fields keep the
browser's own menu, so copying them works as before. The fork dialog
opens from the actions context, like the delete one, so both the icon
and the menu can open it.
Assisted by: Claude (Opus 5.5 Medium)
* ui : scope right-click menu shortcuts and selection to their item
Show the conversation shortcuts only on the active row, as they act on
the active conversation, and keep the browser menu only for a selection
inside the message. Name the close-tab shortcut, and drop the unused
chip toggle, menu submenus and shared cwd row.
Assisted-by: Claude (Opus 5.5 Medium)
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte
index ecd169846..c6b8b3b0c 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte
@@ -11,14 +11,7 @@
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import { ToolsService } from '$lib/services/tools.service';
import { conversationsStore, toolsStore } from '$lib/stores';
- import type { GlobEntry } from '$lib/types';
- import {
- abbreviateHome,
- buildCaseInsensitiveGlob,
- joinPath,
- lastPathSegment,
- runGlobSearchWithChildren
- } from '$lib/utils';
+ import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils';
// Microtask delay so the popover's focus scope tears down first.
const FOCUS_DELAY_MS = 0;
@@ -210,36 +203,15 @@
onChange?.(trimmed);
}
- // Resolve a browser-picked folder name (which exposes only the leaf name)
- // to a server-side absolute path; null when the server cannot locate it,
- // so the caller fails visibly instead of committing a bare leaf name.
- async function resolveNativeName(name: string): Promise<string | null> {
- try {
- const res = await ToolsService.executeToolRaw(BuiltInTool.SERVER_FILE_GLOB_SEARCH, {
- include: buildCaseInsensitiveGlob(name),
- limit: SEARCH.NATIVE_LIMIT,
- max_depth: SEARCH.NATIVE_MAX_DEPTH,
- path: homeBase ?? HOME_TILDE,
- type: GlobSearchType.DIR
- });
- const base = typeof res.base === 'string' ? res.base : '';
- const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
- const match = entries.find(
- (e) => lastPathSegment(e.path).toLowerCase() === name.toLowerCase()
- );
-
- return match ? joinPath(base, match.path) : null;
- } catch {
- return null;
- }
- }
-
+ // The browser picker exposes only the leaf name, so the folder is resolved
+ // to a server-side path first. A folder the server cannot locate keeps the
+ // previous cwd and fails visibly instead of committing a bare leaf name.
async function browseNative() {
if (disabled || !window.showDirectoryPicker) return;
try {
const handle = await window.showDirectoryPicker();
- const path = await resolveNativeName(handle.name);
+ const path = await ToolsService.resolveDirectoryPath(handle.name, homeBase);
if (path) {
setDirectory(path);
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
index 0c32882ef..08a394bb4 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
@@ -112,6 +112,7 @@
let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
let isEditing = $state(false);
let showDeleteDialog = $state(false);
+ let showForkDialog = $state(false);
let shouldBranchAfterEdit = $state(false);
let textareaElement: HTMLTextAreaElement | undefined = $state();
@@ -188,9 +189,15 @@
navigateToSibling: handleNavigateToSibling,
requestDelete: handleDelete,
setShowDeleteDialog: handleShowDeleteDialogChange,
+ setShowForkDialog: (show: boolean) => {
+ showForkDialog = show;
+ },
get showDeleteDialog() {
return showDeleteDialog;
},
+ get showForkDialog() {
+ return showForkDialog;
+ },
get siblingInfo() {
return siblingInfo;
}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte
index ff23acf3c..d443b633a 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte
@@ -6,6 +6,7 @@
ChatMessageAssistantProcessingInfo,
ChatMessageAssistantRawOutput,
ChatMessageAssistantStatistics,
+ ChatMessageContextMenu,
ChatMessageEditForm
} from '$lib/components/app';
import { getChatMessageEditContext } from '$lib/contexts';
@@ -173,16 +174,23 @@
{#if editCtx.isEditing}
<ChatMessageEditForm />
{:else}
- {#if showRawOutput}
- <ChatMessageAssistantRawOutput {message} {toolMessages} />
- {:else}
- <ChatMessageAgenticContent
- {isLastAssistantMessage}
- isStreaming={chatStore.isStreaming()}
- {message}
- {toolMessages}
- />
- {/if}
+ <ChatMessageContextMenu
+ disabled={!message.timestamp}
+ onContinue={currentConfig.enableContinueGeneration ? onContinue : undefined}
+ {onRegenerate}
+ role={MessageRole.ASSISTANT}
+ >
+ {#if showRawOutput}
+ <ChatMessageAssistantRawOutput {message} {toolMessages} />
+ {:else}
+ <ChatMessageAgenticContent
+ {isLastAssistantMessage}
+ isStreaming={chatStore.isStreaming()}
+ {message}
+ {toolMessages}
+ />
+ {/if}
+ </ChatMessageContextMenu>
{/if}
{#if showProcessingInfoBottom}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte
index c2baf67a8..bb0f10f3d 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte
@@ -1,12 +1,13 @@
<script lang="ts">
import {
ChatMessageActionIcons,
+ ChatMessageContextMenu,
ChatMessageEditForm,
ChatMessageStatistics,
ChatMessageUserBubble
} from '$lib/components/app/chat';
import { getChatMessageEditContext } from '$lib/contexts';
- import { ChatMessageStatisticsMode } from '$lib/enums';
+ import { ChatMessageStatisticsMode, MessageRole } from '$lib/enums';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { chatStore, settingsStore } from '$lib/stores';
@@ -72,11 +73,13 @@
{#if editCtx.isEditing}
<ChatMessageEditForm />
{:else}
- <ChatMessageUserBubble
- attachments={message.extra}
- content={message.content}
- renderMarkdown={true}
- />
+ <ChatMessageContextMenu disabled={!message.timestamp} role={MessageRole.USER}>
+ <ChatMessageUserBubble
+ attachments={message.extra}
+ content={message.content}
+ renderMarkdown={true}
+ />
+ </ChatMessageContextMenu>
{#if showStoredReadingStats}
<!-- Reading stats sourced from the assistant message that followed this turn -->
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte
index 041841d34..1e6348c32 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte
@@ -11,6 +11,7 @@
import { Switch } from '$lib/components/ui/switch';
import { getChatMessageActionsContext, getChatMessageEditContext } from '$lib/contexts';
import { conversationsStore } from '$lib/stores';
+ import { untrack } from 'svelte';
interface Props {
justify: 'start' | 'end';
@@ -35,7 +36,6 @@
const messageActions = getChatMessageActionsContext();
const editCtx = getChatMessageEditContext();
- let showForkDialog = $state(false);
let forkName = $state('');
let forkIncludeAttachments = $state(true);
@@ -44,20 +44,23 @@
messageActions.setShowDeleteDialog(false);
}
- function handleOpenForkDialog() {
- const conv = conversationsStore.activeConversation;
+ // fills the fork form each time the dialog opens, from the icon or the
+ // message's context menu
+ $effect(() => {
+ if (!messageActions.showForkDialog) return;
- forkName = `Fork of ${conv?.name ?? 'Conversation'}`;
- forkIncludeAttachments = true;
- showForkDialog = true;
- }
+ untrack(() => {
+ forkName = `Fork of ${conversationsStore.activeConversation?.name ?? 'Conversation'}`;
+ forkIncludeAttachments = true;
+ });
+ });
function handleConfirmFork() {
messageActions.forkConversation?.({
includeAttachments: forkIncludeAttachments,
name: forkName.trim()
});
- showForkDialog = false;
+ messageActions.setShowForkDialog(false);
}
</script>
@@ -87,7 +90,11 @@
{/if}
{#if messageActions.forkConversation}
- <ActionIcon icon={GitBranch} onclick={handleOpenForkDialog} tooltip="Fork conversation" />
+ <ActionIcon
+ icon={GitBranch}
+ onclick={() => messageActions.setShowForkDialog(true)}
+ tooltip="Fork conversation"
+ />
{/if}
<ActionIcon icon={Trash2} onclick={messageActions.requestDelete} tooltip="Delete" />
@@ -123,13 +130,13 @@
/>
<DialogConfirmation
- bind:open={showForkDialog}
cancelText="Cancel"
confirmText="Fork"
description="Create a new conversation branching from this message."
icon={GitBranch}
- onCancel={() => (showForkDialog = false)}
+ onCancel={() => messageActions.setShowForkDialog(false)}
onConfirm={handleConfirmFork}
+ open={messageActions.showForkDialog}
title="Fork Conversation"
>
<div class="flex flex-col gap-4 py-2">
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageContextMenu.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageContextMenu.svelte
new file mode 100644
index 000000000..74e05dd44
--- /dev/null
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageContextMenu.svelte
@@ -0,0 +1,70 @@
+<script lang="ts">
+ import { ArrowRight, Copy, Edit, GitBranch, RefreshCw, Trash2 } from '@lucide/svelte';
+ import { ContextMenuActions } from '$lib/components/app';
+ import { NATIVE_CONTEXT_MENU_SELECTOR } from '$lib/constants';
+ import { getChatMessageActionsContext, getChatMessageEditContext } from '$lib/contexts';
+ import { MessageRole } from '$lib/enums';
+ import type { MenuAction } from '$lib/types';
+ import type { Snippet } from 'svelte';
+
+ interface Props {
+ role: MessageRole.USER | MessageRole.ASSISTANT;
+ children: Snippet;
+ disabled?: boolean;
+ onRegenerate?: () => void;
+ onContinue?: () => void;
+ }
+
+ let { children, disabled = false, onContinue, onRegenerate, role }: Props = $props();
+
+ const messageActions = getChatMessageActionsContext();
+ const editCtx = getChatMessageEditContext();
+
+ // the same actions as the message's action icons
+ let actions = $derived<MenuAction[]>([
+ { icon: Copy, label: 'Copy', onclick: messageActions.copy },
+ { icon: Edit, label: 'Edit', onclick: editCtx.startEdit },
+ ...(role === MessageRole.ASSISTANT && onRegenerate
+ ? [{ icon: RefreshCw, label: 'Regenerate', onclick: () => onRegenerate() }]
+ : []),
+ ...(role === MessageRole.ASSISTANT && onContinue
+ ? [{ icon: ArrowRight, label: 'Continue', onclick: () => onContinue() }]
+ : []),
+ ...(messageActions.forkConversation
+ ? [
+ {
+ icon: GitBranch,
+ label: 'Fork conversation',
+ onclick: () => messageActions.setShowForkDialog(true)
+ }
+ ]
+ : []),
+ {
+ icon: Trash2,
+ label: 'Delete',
+ onclick: messageActions.requestDelete,
+ separator: true,
+ variant: 'destructive'
+ }
+ ]);
+
+ // Selected text, links, media, code and fields keep the browser's menu: the
+ // right-click stops here, before the context menu trigger around it sees it.
+ function keepNativeMenu(event: MouseEvent) {
+ const selection = window.getSelection();
+ // only a selection inside this message counts, not one left elsewhere on the page
+ const hasSelection =
+ !!selection?.toString().trim() && selection.containsNode(event.currentTarget as Node, true);
+ const target = event.target instanceof Element ? event.target : null;
+
+ if (hasSelection || target?.closest(NATIVE_CONTEXT_MENU_SELECTOR)) {
+ event.stopPropagation();
+ }
+ }
+</script>
+
+<ContextMenuActions {actions} {disabled}>
+ <div class="contents" oncontextmenucapture={keepNativeMenu}>
+ {@render children()}
+ </div>
+</ContextMenuActions>
diff --git a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte
index 6a47029b1..515bf4f78 100644
--- a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte
@@ -1,18 +1,20 @@
<script lang="ts">
import ChatTabsItem from './ChatTabsItem.svelte';
import ChatTabsNewChatButton from './ChatTabsNewChatButton.svelte';
- import { SquarePen } from '@lucide/svelte';
+ import { ArrowRightToLine, ListX, Square, SquarePen, X } from '@lucide/svelte';
import { page } from '$app/state';
- import { ScrollCarousel } from '$lib/components/app';
+ import { ContextMenuActions, ScrollCarousel } from '$lib/components/app';
import {
CHAT_TABS_MAX_WIDTH,
NEW_CHAT_LABEL,
NEW_CHAT_TAB_ID,
+ SHORTCUT_CLOSE_TAB,
UI_DATA_ATTRS,
UNNAMED_CHAT_LABEL
} from '$lib/constants';
import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
import { chatStore, conversationsStore, tabsStore, uiStore } from '$lib/stores';
+ import type { MenuAction } from '$lib/types';
import { resolveTabView } from '$lib/utils';
import { tick } from 'svelte';
@@ -49,6 +51,44 @@
void chatStore.stopGenerationForChat(id);
}
+ function tabActions(id: string): MenuAction[] {
+ const isLoading = loadingIds.has(id);
+ const others = view.ids.filter((tabId) => tabId !== id);
+ const toTheRight = view.ids.slice(view.ids.indexOf(id) + 1);
+
+ return [
+ ...(isLoading
+ ? [
+ {
+ icon: Square,
+ label: 'Stop generation',
+ onclick: () => void chatStore.stopGenerationForChat(id)
+ }
+ ]
+ : []),
+ {
+ icon: X,
+ label: 'Close tab',
+ onclick: () => handleClose(id),
+ separator: isLoading,
+ // the shortcut closes the active tab only
+ shortcut: id === activeId ? SHORTCUT_CLOSE_TAB : undefined
+ },
+ {
+ disabled: others.length === 0,
+ icon: ListX,
+ label: 'Close other tabs',
+ onclick: () => void tabsStore.closeMany(others, id, activeId, view)
+ },
+ {
+ disabled: toTheRight.length === 0,
+ icon: ArrowRightToLine,
+ label: 'Close tabs to the right',
+ onclick: () => void tabsStore.closeMany(toTheRight, id, activeId, view)
+ }
+ ];
+ }
+
function handleAuxClick(id: string, event: MouseEvent) {
// middle-click closes, like browser tabs
if (event.button === 1) {
@@ -103,15 +143,17 @@
innerClass="items-center gap-1.25"
>
{#each tabs as tab (tab.id)}
- <ChatTabsItem
- isActive={tab.id === activeId}
- isLoading={loadingIds.has(tab.id)}
- onActivate={(id) => tabsStore.activate(id)}
- onAuxClick={handleAuxClick}
- onClose={handleClose}
- onStop={handleStop}
- {tab}
- />
+ <ContextMenuActions actions={tabActions(tab.id)}>
+ <ChatTabsItem
+ isActive={tab.id === activeId}
+ isLoading={loadingIds.has(tab.id)}
+ onActivate={(id) => tabsStore.activate(id)}
+ onAuxClick={handleAuxClick}
+ onClose={handleClose}
+ onStop={handleStop}
+ {tab}
+ />
+ </ContextMenuActions>
{/each}
{#if showNewChatButton}
diff --git a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte
index 30a0af931..5435abcf9 100644
--- a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte
@@ -67,7 +67,9 @@
{...{ [UI_DATA_ATTRS.ACTIVE_TAB]: isActive ? 'true' : undefined }}
class={cn(
TAB_SURFACE_CLASS,
- 'relative max-w-52 min-w-0 shrink-0 gap-1 pr-1 first:ml-2',
+ // each tab sits in its context-menu trigger, so the first trigger marks
+ // the first tab
+ 'relative max-w-52 min-w-0 shrink-0 gap-1 pr-1 [:first-child>&]:ml-2',
isLoading ? 'pl-1' : 'pl-3',
isActive ? TAB_SURFACE_ACTIVE_CLASS : TAB_SURFACE_IDLE_CLASS
)}
diff --git a/tools/ui/src/lib/components/app/chat/index.ts b/tools/ui/src/lib/components/app/chat/index.ts
index aa1101cf5..6f0af1a33 100644
--- a/tools/ui/src/lib/components/app/chat/index.ts
+++ b/tools/ui/src/lib/components/app/chat/index.ts
@@ -515,6 +515,13 @@ export { default as ChatMessageActionIcons } from './ChatMessages/ChatMessageAct
*/
export { default as ChatMessageActionIconsBranchingControls } from './ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIconsBranchingControls.svelte';
+/**
+ * Right-click menu for user and assistant messages with the same actions as
+ * their action icons. Selected text, links, media, code and fields keep the
+ * browser's own menu.
+ */
+export { default as ChatMessageContextMenu } from './ChatMessages/ChatMessageActions/ChatMessageContextMenu.svelte';
+
/**
* Statistics display for assistant messages. Shows token counts (prompt/completion),
* generation timing, tokens per second, and model name (when enabled in settings).
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte
index 8383d418a..ef21f01ec 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte
@@ -9,7 +9,7 @@
import { modelRowActions } from './row-actions';
import { configuredContext, downloadProgressFor, isRowControlClick } from './utils';
import { Heart, MoreHorizontal } from '@lucide/svelte';
- import { DropdownMenuActions } from '$lib/components/app';
+ import { ContextMenuActions, DropdownMenuActions } from '$lib/components/app';
import { MODEL_ROW_GRID_CLASS, MODEL_ROW_TRAILING_CELL_CLASS } from '$lib/constants';
import { ModelRowDownloadState } from '$lib/enums';
import { deviceStore, modelsStore } from '$lib/stores';
@@ -53,98 +53,107 @@
function activate(): void {
onSelect(option);
}
+
+ // the "more actions" dropdown and the right-click menu offer the same actions
+ let actions = $derived(modelRowActions(option, favorite, isHidden, onDelete, download));
+ let contextMenuOpen = $state(false);
</script>
-<!-- the whole row opens the model for a pointer; keyboard users reach the same action
+<ContextMenuActions bind:open={contextMenuOpen} {actions}>
+ <!-- the whole row opens the model for a pointer; keyboard users reach the same action
through the model's own button -->
-<!-- svelte-ignore a11y_click_events_have_key_events -->
-<!-- svelte-ignore a11y_no_static_element_interactions -->
-<div
- class={[
- MODEL_ROW_GRID_CLASS,
- 'group relative cursor-pointer rounded-md px-2 py-3 transition max-md:px-3 max-md:py-4',
- isHidden && 'opacity-60',
- selected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/40'
- ]}
- onclick={(event) => {
- if (!isRowControlClick(event)) activate();
- }}
->
- <div class="flex min-w-0 flex-col">
- <!-- the row holds a load control and an actions menu, so only the model itself is
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <div
+ class={[
+ MODEL_ROW_GRID_CLASS,
+ 'group relative cursor-pointer rounded-md px-2 py-3 transition max-md:px-3 max-md:py-4',
+ isHidden && 'opacity-60',
+ selected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/40',
+ contextMenuOpen && !selected && 'bg-muted/40'
+ ]}
+ onclick={(event) => {
+ if (!isRowControlClick(event)) activate();
+ }}
+ >
+ <div class="flex min-w-0 flex-col">
+ <!-- the row holds a load control and an actions menu, so only the model itself is
the button: a button nested in a role="button" row is invalid -->
- <button
- aria-pressed={download ? undefined : selected}
- class="flex min-w-0 cursor-pointer items-center gap-3 text-left max-md:gap-2 max-md:pr-9"
- onclick={activate}
- style="padding-left: {indent}px"
- type="button"
- >
- <ModelAvatar {option} size="size-7 md:size-9" />
+ <button
+ aria-pressed={download ? undefined : selected}
+ class="flex min-w-0 cursor-pointer items-center gap-3 text-left max-md:gap-2 max-md:pr-9"
+ onclick={activate}
+ style="padding-left: {indent}px"
+ type="button"
+ >
+ <ModelAvatar {option} size="size-7 md:size-9" />
- <span class="flex min-w-0 items-center gap-1.25">
- <ModelId
- aliases={option.aliases}
- class="min-w-0 flex-1 max-md:text-sm"
- draftSidecars={option.draftSidecars}
- hideCapabilities
- hideModalities
- modalities={option.modalities}
- modelId={option.model}
- tags={option.tags}
- title={option.model}
- />
+ <span class="flex min-w-0 items-center gap-1.25">
+ <ModelId
+ aliases={option.aliases}
+ class="min-w-0 flex-1 max-md:text-sm"
+ draftSidecars={option.draftSidecars}
+ hideCapabilities
+ hideModalities
+ modalities={option.modalities}
+ modelId={option.model}
+ tags={option.tags}
+ title={option.model}
+ />
- <!-- a phone has no width for the modality icons, the id needs it more -->
- <ModelCapabilities hideModalities={deviceStore.isMobile} {option} />
+ <!-- a phone has no width for the modality icons, the id needs it more -->
+ <ModelCapabilities hideModalities={deviceStore.isMobile} {option} />
- {#if favorite}
- <!-- the heart is decorative, the button's text carries the state -->
- <Heart aria-hidden="true" class="h-3.5 w-3.5 shrink-0 text-rose-500" />
+ {#if favorite}
+ <!-- the heart is decorative, the button's text carries the state -->
+ <Heart aria-hidden="true" class="h-3.5 w-3.5 shrink-0 text-rose-500" />
- <span class="sr-only">favorited</span>
- {/if}
- </span>
- </button>
+ <span class="sr-only">favorited</span>
+ {/if}
+ </span>
+ </button>
- {#if sidecarBadges?.length}
- <!-- w-fit keeps the trailing state beside the badges instead of floating
+ {#if sidecarBadges?.length}
+ <!-- w-fit keeps the trailing state beside the badges instead of floating
to the end of the wide model column -->
- <div class="flex w-fit flex-col pb-1">
- {#each sidecarBadges as badge (badge.repo + ':' + badge.kind + ':' + (badge.quant ?? ''))}
- <ModelDraftSubline
- connectorClass="ml-8"
- kind={badge.kind}
- quant={badge.quant}
- state={ModelRowDownloadState.DOWNLOADED}
- />
- {/each}
- </div>
- {/if}
- </div>
+ <div class="flex w-fit flex-col pb-1">
+ {#each sidecarBadges as badge (badge.repo + ':' + badge.kind + ':' + (badge.quant ?? ''))}
+ <ModelDraftSubline
+ connectorClass="ml-8"
+ kind={badge.kind}
+ quant={badge.quant}
+ state={ModelRowDownloadState.DOWNLOADED}
+ />
+ {/each}
+ </div>
+ {/if}
+ </div>
- <ModelContext
- class="justify-self-end max-md:hidden"
- configured={configuredContext(option)}
- {option}
- />
+ <ModelContext
+ class="justify-self-end max-md:hidden"
+ configured={configuredContext(option)}
+ {option}
+ />
- <ModelsManagerStatusCell class="max-md:hidden" {download} {option} />
+ <ModelsManagerStatusCell class="max-md:hidden" {download} {option} />
- {#if download}
- <ModelDownloadProgressBar
- downloadedBytes={downloadProgress?.downloadedBytes ?? 0}
- overlay
- totalBytes={downloadProgress?.totalBytes ?? 0}
- />
- {/if}
+ {#if download}
+ <ModelDownloadProgressBar
+ downloadedBytes={downloadProgress?.downloadedBytes ?? 0}
+ overlay
+ totalBytes={downloadProgress?.totalBytes ?? 0}
+ />
+ {/if}
- <div class="flex items-center justify-center justify-self-center {MODEL_ROW_TRAILING_CELL_CLASS}">
- <DropdownMenuActions
- actions={modelRowActions(option, favorite, isHidden, onDelete, download)}
- align="end"
- triggerIcon={MoreHorizontal}
- triggerTooltip="Model actions"
- />
+ <div
+ class="flex items-center justify-center justify-self-center {MODEL_ROW_TRAILING_CELL_CLASS}"
+ >
+ <DropdownMenuActions
+ {actions}
+ align="end"
+ triggerIcon={MoreHorizontal}
+ triggerTooltip="Model actions"
+ />
+ </div>
</div>
-</div>
+</ContextMenuActions>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte
index 6838a85da..e99cbea2c 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte
@@ -5,7 +5,7 @@
import { modelRowActions } from './row-actions';
import { configuredContext, isRowControlClick } from './utils';
import { MoreHorizontal } from '@lucide/svelte';
- import { DropdownMenuActions } from '$lib/components/app';
+ import { ContextMenuActions, DropdownMenuActions } from '$lib/components/app';
import { Badge } from '$lib/components/ui/badge';
import { MODEL_ROW_GRID_CLASS, MODEL_ROW_TRAILING_CELL_CLASS } from '$lib/constants';
import { modelsStore } from '$lib/stores';
@@ -26,53 +26,62 @@
let favorite = $derived(isFavorite(option));
let isHidden = $derived(modelsStore.isHidden(option.id));
let quant = $derived(option.parsedId?.quantization ?? option.model);
+
+ // the "more actions" dropdown and the right-click menu offer the same actions
+ let actions = $derived(modelRowActions(option, favorite, isHidden, onDelete));
+ let contextMenuOpen = $state(false);
</script>
-<!-- the whole row opens the quant for a pointer; keyboard users reach the same action
+<ContextMenuActions bind:open={contextMenuOpen} {actions}>
+ <!-- the whole row opens the quant for a pointer; keyboard users reach the same action
through the quant's own button -->
-<!-- svelte-ignore a11y_click_events_have_key_events -->
-<!-- svelte-ignore a11y_no_static_element_interactions -->
-<div
- class={[
- MODEL_ROW_GRID_CLASS,
- 'group relative cursor-pointer rounded-md px-2 py-2 transition max-md:px-3 max-md:py-3',
- isHidden && 'opacity-60',
- selected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/40'
- ]}
- onclick={(event) => {
- if (!isRowControlClick(event)) onSelect(option);
- }}
->
- <!-- the row holds a load control and an actions menu, so only the quant itself is
- the button: a button nested in a role="button" row is invalid -->
- <button
- aria-pressed={selected}
- class="flex min-w-0 cursor-pointer items-center gap-3 text-left max-md:pr-9"
- onclick={() => onSelect(option)}
- style="padding-left: {indent}px"
- type="button"
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <div
+ class={[
+ MODEL_ROW_GRID_CLASS,
+ 'group relative cursor-pointer rounded-md px-2 py-2 transition max-md:px-3 max-md:py-3',
+ isHidden && 'opacity-60',
+ selected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/40',
+ contextMenuOpen && !selected && 'bg-muted/40'
+ ]}
+ onclick={(event) => {
+ if (!isRowControlClick(event)) onSelect(option);
+ }}
>
- <Badge class="h-5 shrink-0 px-1.5 text-[10px]" variant="secondary">{quant}</Badge>
+ <!-- the row holds a load control and an actions menu, so only the quant itself is
+ the button: a button nested in a role="button" row is invalid -->
+ <button
+ aria-pressed={selected}
+ class="flex min-w-0 cursor-pointer items-center gap-3 text-left max-md:pr-9"
+ onclick={() => onSelect(option)}
+ style="padding-left: {indent}px"
+ type="button"
+ >
+ <Badge class="h-5 shrink-0 px-1.5 text-[10px]" variant="secondary">{quant}</Badge>
- <ModelDraftSidecars draftSidecars={option.draftSidecars} />
+ <ModelDraftSidecars draftSidecars={option.draftSidecars} />
- <span class="truncate text-sm text-muted-foreground">{option.model}</span>
- </button>
+ <span class="truncate text-sm text-muted-foreground">{option.model}</span>
+ </button>
- <ModelContext
- class="justify-self-end max-md:hidden"
- configured={configuredContext(option)}
- {option}
- />
+ <ModelContext
+ class="justify-self-end max-md:hidden"
+ configured={configuredContext(option)}
+ {option}
+ />
- <ModelsManagerStatusCell class="max-md:hidden" {option} />
+ <ModelsManagerStatusCell class="max-md:hidden" {option} />
- <div class="flex items-center justify-center justify-self-center {MODEL_ROW_TRAILING_CELL_CLASS}">
- <DropdownMenuActions
- actions={modelRowActions(option, favorite, isHidden, onDelete)}
- align="end"
- triggerIcon={MoreHorizontal}
- triggerTooltip="Model actions"
- />
+ <div
+ class="flex items-center justify-center justify-self-center {MODEL_ROW_TRAILING_CELL_CLASS}"
+ >
+ <DropdownMenuActions
+ {actions}
+ align="end"
+ triggerIcon={MoreHorizontal}
+ triggerTooltip="Model actions"
+ />
+ </div>
</div>
-</div>
+</ContextMenuActions>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts b/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts
index 42c282e05..b98ab6bdf 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts
@@ -2,6 +2,7 @@ import { Compass, Eye, EyeOff, Heart, HeartOff, Trash2 } from '@lucide/svelte';
import { MODEL_DOWNLOAD_ICONS } from '$lib/constants';
import { ModelRowDownloadState } from '$lib/enums';
import { modelsStore, uiStore } from '$lib/stores';
+import type { MenuAction } from '$lib/types';
import type { ModelOption } from '$lib/types/models';
import { repoOf } from '$lib/utils';
@@ -12,7 +13,7 @@ export function modelRowActions(
onDelete: (option: ModelOption) => void,
/** Download state, when the row stands for a tracked download. */
download?: ModelRowDownloadState | null
-) {
+): MenuAction[] {
// the details pane is keyed by repo, not by `<repo>:<quant>`; the entry exists only
// while Discover is on
const viewInDiscover = uiStore.isDiscoverEnabled
diff --git a/tools/ui/src/lib/components/app/navigation/ContextMenuActions.svelte b/tools/ui/src/lib/components/app/navigation/ContextMenuActions.svelte
new file mode 100644
index 000000000..ca0f7de60
--- /dev/null
+++ b/tools/ui/src/lib/components/app/navigation/ContextMenuActions.svelte
@@ -0,0 +1,31 @@
+<script lang="ts">
+ import MenuActionsList from './MenuActionsList.svelte';
+ import * as ContextMenu from '$lib/components/ui/context-menu';
+ import { MenuKind } from '$lib/enums';
+ import { deviceStore } from '$lib/stores';
+ import type { MenuAction } from '$lib/types';
+ import type { Snippet } from 'svelte';
+
+ interface Props {
+ actions: MenuAction[];
+ children: Snippet;
+ /** Leave right-click to the browser, e.g. while the item is being selected. */
+ disabled?: boolean;
+ open?: boolean;
+ }
+
+ let { actions, children, disabled = false, open = $bindable(false) }: Props = $props();
+</script>
+
+<!-- the trigger takes no box of its own, so the item lays out as if unwrapped; a
+ phone keeps the browser's long-press and reaches the actions from the item's
+ "more actions" drawer instead -->
+<ContextMenu.Root bind:open>
+ <ContextMenu.Trigger class="contents" disabled={disabled || deviceStore.isMobile}>
+ {@render children()}
+ </ContextMenu.Trigger>
+
+ <ContextMenu.Content class="z-[999999] w-48">
+ <MenuActionsList {actions} kind={MenuKind.CONTEXT} />
+ </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 f31430506..49d38d45d 100644
--- a/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte
+++ b/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte
@@ -1,29 +1,20 @@
<script lang="ts">
+ import MenuActionsList from './MenuActionsList.svelte';
import { KeyboardShortcutInfo } from '$lib/components/app';
import * as Drawer from '$lib/components/ui/drawer';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Separator } from '$lib/components/ui/separator';
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 ActionItem {
- icon: Component;
- label: string;
- onclick: (event: Event) => void;
- variant?: 'default' | 'destructive';
- disabled?: boolean;
- /** Shown on hover, e.g. why a disabled action is not available. */
- tooltip?: string;
- shortcut?: string[];
- separator?: boolean;
- }
-
interface Props {
triggerIcon: Component;
triggerTooltip?: string;
triggerClass?: string;
- actions: ActionItem[];
+ actions: MenuAction[];
align?: 'start' | 'center' | 'end';
open?: boolean;
}
@@ -41,7 +32,7 @@
// row is a touch target of its own
let isMobile = $derived(deviceStore.isMobile);
- function runAction(action: ActionItem, event: Event) {
+ function runAction(action: MenuAction, event: Event) {
open = false;
// let the drawer close before an action that moves the focus, such as a confirm dialog
@@ -135,55 +126,11 @@
</Tooltip.Root>
<DropdownMenu.Content {align} class="z-[999999] w-48">
- {#each actions as action, index (action.label)}
- {#if action.separator && index > 0}
- <DropdownMenu.Separator />
- {/if}
-
- {#if action.tooltip}
- <!-- a disabled item takes no pointer events, so the wrapper holds the hover -->
- <Tooltip.Root>
- <Tooltip.Trigger>
- {#snippet child({ props })}
- <div {...props}>
- {@render menuItem(action)}
- </div>
- {/snippet}
- </Tooltip.Trigger>
-
- <Tooltip.Content class="z-[999999] max-w-64" side="left">
- <p>{action.tooltip}</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {:else}
- {@render menuItem(action)}
- {/if}
- {/each}
+ <MenuActionsList {actions} kind={MenuKind.DROPDOWN} />
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
-{#snippet menuItem(action: ActionItem)}
- <DropdownMenu.Item
- class="flex items-center justify-between hover:[&>kbd]:opacity-100"
- disabled={action.disabled}
- onclick={action.onclick}
- variant={action.variant}
- >
- <div class="flex items-center gap-2">
- {@render iconComponent(
- action.icon,
- `h-4 w-4 ${action.variant === 'destructive' ? 'text-destructive' : ''}`
- )}
- {action.label}
- </div>
-
- {#if action.shortcut}
- <KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} />
- {/if}
- </DropdownMenu.Item>
-{/snippet}
-
{#snippet iconComponent(IconComponent: Component, className: string)}
<IconComponent class={className} />
{/snippet}
diff --git a/tools/ui/src/lib/components/app/navigation/MenuActionsList.svelte b/tools/ui/src/lib/components/app/navigation/MenuActionsList.svelte
new file mode 100644
index 000000000..c48076bb9
--- /dev/null
+++ b/tools/ui/src/lib/components/app/navigation/MenuActionsList.svelte
@@ -0,0 +1,69 @@
+<script lang="ts">
+ import { KeyboardShortcutInfo } from '$lib/components/app';
+ import * as ContextMenu from '$lib/components/ui/context-menu';
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { MenuKind } from '$lib/enums';
+ import type { MenuAction } from '$lib/types';
+ import type { Component } from 'svelte';
+
+ interface Props {
+ actions: MenuAction[];
+ kind: MenuKind;
+ }
+
+ let { actions, kind }: Props = $props();
+
+ // both kits wrap the same bits-ui menu parts, so the rows render alike
+ let Menu = $derived(kind === MenuKind.CONTEXT ? ContextMenu : DropdownMenu);
+</script>
+
+{#each actions as action, index (action.label)}
+ {#if action.separator && index > 0}
+ <Menu.Separator />
+ {/if}
+
+ {#if action.tooltip}
+ <!-- a disabled item takes no pointer events, so the wrapper holds the hover -->
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ {#snippet child({ props })}
+ <div {...props}>
+ {@render menuItem(action)}
+ </div>
+ {/snippet}
+ </Tooltip.Trigger>
+
+ <Tooltip.Content class="z-[999999] max-w-64" side="left">
+ <p>{action.tooltip}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {:else}
+ {@render menuItem(action)}
+ {/if}
+{/each}
+
+{#snippet menuItem(action: MenuAction)}
+ <Menu.Item
+ class="flex items-center justify-between hover:[&>kbd]:opacity-100"
+ disabled={action.disabled}
+ onclick={action.onclick}
+ variant={action.variant}
+ >
+ <div class="flex items-center gap-2">
+ {@render iconComponent(
+ action.icon,
+ `h-4 w-4 ${action.variant === 'destructive' ? 'text-destructive' : ''}`
+ )}
+ {action.label}
+ </div>
+
+ {#if action.shortcut}
+ <KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} />
+ {/if}
+ </Menu.Item>
+{/snippet}
+
+{#snippet iconComponent(IconComponent: Component, className: string)}
+ <IconComponent class={className} />
+{/snippet}
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 5db6066b0..e4fc91cb8 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
@@ -12,7 +12,7 @@
Square,
Trash2
} from '@lucide/svelte';
- import { DropdownMenuActions } from '$lib/components/app';
+ import { ContextMenuActions, DropdownMenuActions } from '$lib/components/app';
import { TruncatedText } from '$lib/components/app';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -26,6 +26,7 @@
} from '$lib/constants';
import { RouterService } from '$lib/services/router.service';
import { chatStore, conversationsStore, settingsStore, tabsStore } from '$lib/stores';
+ import type { MenuAction } from '$lib/types';
import { onMount } from 'svelte';
interface Props {
@@ -62,6 +63,7 @@
let renderActionsDropdown = $state(false);
let dropdownOpen = $state(false);
+ let contextMenuOpen = $state(false);
let isLoading = $derived(chatStore.getAllLoadingChats().includes(conversation.id));
@@ -94,6 +96,60 @@
openInNewTab();
}
+ // 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[]>([
+ ...(canOpenInNewTab
+ ? [
+ {
+ icon: PanelTopOpen,
+ label: 'Open in new tab',
+ onclick: (e: Event) => {
+ e.stopPropagation();
+ openInNewTab();
+ }
+ }
+ ]
+ : []),
+ {
+ icon: conversation.pinned ? PinOff : Pin,
+ label: conversation.pinned ? 'Unpin' : 'Pin',
+ onclick: (e: Event) => {
+ e.stopPropagation();
+ handleTogglePin();
+ },
+ separator: canOpenInNewTab
+ },
+ {
+ icon: Pencil,
+ label: 'Edit',
+ onclick: handleEdit,
+ shortcut: isActive ? SHORTCUT_EDIT_CONVERSATION : undefined
+ },
+ {
+ icon: Download,
+ label: 'Export',
+ onclick: (e: Event) => {
+ e.stopPropagation();
+ conversationsStore.downloadConversation(conversation.id);
+ },
+ shortcut: isActive ? SHORTCUT_EXPORT_CONVERSATION : undefined
+ },
+ {
+ icon: ListChecks,
+ label: 'Select',
+ onclick: handleEnterSelectionMode
+ },
+ {
+ icon: Trash2,
+ label: 'Delete',
+ onclick: handleDelete,
+ separator: true,
+ shortcut: isActive ? SHORTCUT_DELETE_CONVERSATION : undefined,
+ variant: 'destructive'
+ }
+ ]);
+
function handleTogglePin() {
conversationsStore.toggleConversationPin(conversation.id);
}
@@ -178,154 +234,108 @@
});
</script>
-<button
- class="group flex min-h-9 w-full cursor-pointer items-center justify-between space-x-3 rounded-lg py-1.5 text-left transition-colors hover:bg-foreground/10 {isActive
- ? 'bg-foreground/5 text-accent-foreground'
- : ''} {isSelected ? 'bg-primary/10 hover:bg-primary/15' : ''} {isSelectionMode
- ? 'is-selection-mode'
- : ''} px-2"
- {...{ [UI_DATA_ATTRS.CONVERSATION_ROW]: conversation.id }}
- onauxclick={handleAuxClick}
- onclick={(e) => handleSelect(e)}
- onfocusin={handleMouseOver}
- onfocusout={(e) => {
- if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
- handleMouseLeave();
- }
- }}
- onmousedown={(e) => handleRowMouseDown(e)}
- onmouseleave={handleMouseLeave}
- onmouseover={handleMouseOver}
->
- <div
- style:padding-left="{depth * FORK_TREE_DEPTH_PADDING}px"
- class="flex min-w-0 flex-1 items-center gap-2"
+<ContextMenuActions bind:open={contextMenuOpen} {actions} disabled={isSelectionMode}>
+ <button
+ class="group flex min-h-9 w-full cursor-pointer items-center justify-between space-x-3 rounded-lg py-1.5 text-left transition-colors hover:bg-foreground/10 {isActive
+ ? 'bg-foreground/5 text-accent-foreground'
+ : ''} {contextMenuOpen ? 'bg-foreground/10' : ''} {isSelected
+ ? 'bg-primary/10 hover:bg-primary/15'
+ : ''} {isSelectionMode ? 'is-selection-mode' : ''} px-2"
+ {...{ [UI_DATA_ATTRS.CONVERSATION_ROW]: conversation.id }}
+ onauxclick={handleAuxClick}
+ onclick={(e) => handleSelect(e)}
+ onfocusin={handleMouseOver}
+ onfocusout={(e) => {
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
+ handleMouseLeave();
+ }
+ }}
+ onmousedown={(e) => handleRowMouseDown(e)}
+ onmouseleave={handleMouseLeave}
+ onmouseover={handleMouseOver}
>
- {#if isSelectionMode}
- <div
- aria-checked={isSelected}
- aria-label={isSelected ? `Deselect ${conversation.name}` : `Select ${conversation.name}`}
- class="shrink-0"
- onclick={(e) => handleCheckboxClick(e)}
- onkeydown={handleCheckboxKeydown}
- role="checkbox"
- tabindex="-1"
- >
- <Checkbox
+ <div
+ style:padding-left="{depth * FORK_TREE_DEPTH_PADDING}px"
+ class="flex min-w-0 flex-1 items-center gap-2"
+ >
+ {#if isSelectionMode}
+ <div
+ aria-checked={isSelected}
aria-label={isSelected ? `Deselect ${conversation.name}` : `Select ${conversation.name}`}
- checked={isSelected}
- />
- </div>
- {/if}
-
- {#if depth > 0}
- <Tooltip.Root>
- <Tooltip.Trigger>
- <!-- prevent another nested button element -->
- {#snippet child({ props })}
- <a
- {...props}
- class="flex shrink-0 items-center text-muted-foreground transition-colors hover:text-foreground"
- href={RouterService.chat(conversation.forkedFromConversationId)}
+ class="shrink-0"
+ onclick={(e) => handleCheckboxClick(e)}
+ onkeydown={handleCheckboxKeydown}
+ role="checkbox"
+ tabindex="-1"
+ >
+ <Checkbox
+ aria-label={isSelected
+ ? `Deselect ${conversation.name}`
+ : `Select ${conversation.name}`}
+ checked={isSelected}
+ />
+ </div>
+ {/if}
+
+ {#if depth > 0}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <!-- prevent another nested button element -->
+ {#snippet child({ props })}
+ <a
+ {...props}
+ class="flex shrink-0 items-center text-muted-foreground transition-colors hover:text-foreground"
+ href={RouterService.chat(conversation.forkedFromConversationId)}
+ >
+ <GitBranch class="h-3.5 w-3.5" />
+ </a>
+ {/snippet}
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>See parent conversation</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+
+ {#if isLoading}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <div
+ aria-label="Stop generation"
+ class="stop-button flex {ICON_CLASS_DEFAULT} shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
+ onclick={handleStop}
+ onkeydown={(e) => e.key === 'Enter' && handleStop(e)}
+ role="button"
+ tabindex="0"
>
- <GitBranch class="h-3.5 w-3.5" />
- </a>
- {/snippet}
- </Tooltip.Trigger>
-
- <Tooltip.Content>
- <p>See parent conversation</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {/if}
+ <Loader2 class="loading-icon h-3.5 w-3.5 animate-spin" />
- {#if isLoading}
- <Tooltip.Root>
- <Tooltip.Trigger>
- <div
- aria-label="Stop generation"
- class="stop-button flex {ICON_CLASS_DEFAULT} shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
- onclick={handleStop}
- onkeydown={(e) => e.key === 'Enter' && handleStop(e)}
- role="button"
- tabindex="0"
- >
- <Loader2 class="loading-icon h-3.5 w-3.5 animate-spin" />
-
- <Square class="stop-icon hidden h-3 w-3 fill-current text-destructive" />
- </div>
- </Tooltip.Trigger>
-
- <Tooltip.Content>
- <p>Stop generation</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {/if}
+ <Square class="stop-icon hidden h-3 w-3 fill-current text-destructive" />
+ </div>
+ </Tooltip.Trigger>
- <TruncatedText class="text-sm font-medium" showTooltip={false} text={conversation.name} />
- </div>
-
- {#if !isSelectionMode && renderActionsDropdown}
- <div class="actions flex items-center">
- <DropdownMenuActions
- bind:open={dropdownOpen}
- actions={[
- ...(canOpenInNewTab
- ? [
- {
- icon: PanelTopOpen,
- label: 'Open in new tab',
- onclick: (e: Event) => {
- e.stopPropagation();
- openInNewTab();
- }
- }
- ]
- : []),
- {
- icon: conversation.pinned ? PinOff : Pin,
- label: conversation.pinned ? 'Unpin' : 'Pin',
- onclick: (e: Event) => {
- e.stopPropagation();
- handleTogglePin();
- },
- separator: canOpenInNewTab
- },
- {
- icon: Pencil,
- label: 'Edit',
- onclick: handleEdit,
- shortcut: SHORTCUT_EDIT_CONVERSATION
- },
- {
- icon: Download,
- label: 'Export',
- onclick: (e: Event) => {
- e.stopPropagation();
- conversationsStore.downloadConversation(conversation.id);
- },
- shortcut: SHORTCUT_EXPORT_CONVERSATION
- },
- {
- icon: ListChecks,
- label: 'Select',
- onclick: handleEnterSelectionMode
- },
- {
- icon: Trash2,
- label: 'Delete',
- onclick: handleDelete,
- separator: true,
- shortcut: SHORTCUT_DELETE_CONVERSATION,
- variant: 'destructive'
- }
- ]}
- triggerIcon={MoreHorizontal}
- triggerTooltip="More actions"
- />
+ <Tooltip.Content>
+ <p>Stop generation</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+
+ <TruncatedText class="text-sm font-medium" showTooltip={false} text={conversation.name} />
</div>
- {/if}
-</button>
+
+ {#if !isSelectionMode && renderActionsDropdown}
+ <div class="actions flex items-center">
+ <DropdownMenuActions
+ bind:open={dropdownOpen}
+ {actions}
+ triggerIcon={MoreHorizontal}
+ triggerTooltip="More actions"
+ />
+ </div>
+ {/if}
+ </button>
+</ContextMenuActions>
<style>
button {
diff --git a/tools/ui/src/lib/components/app/navigation/index.ts b/tools/ui/src/lib/components/app/navigation/index.ts
index aeee92051..1ebf08026 100644
--- a/tools/ui/src/lib/components/app/navigation/index.ts
+++ b/tools/ui/src/lib/components/app/navigation/index.ts
@@ -63,6 +63,22 @@ export { default as DropdownMenuSearchable } from './DropdownMenuSearchable.svel
* ```
*/
export { default as DropdownMenuActions } from './DropdownMenuActions.svelte';
+
+/**
+ * **ContextMenuActions** - Right-click menu with an item's actions
+ *
+ * Wraps an item so right-clicking it opens the same actions as its
+ * DropdownMenuActions "more actions" menu. Adds no element to the layout and
+ * stays out of the way on phones, where the dropdown's drawer covers it.
+ *
+ * @example
+ * ```svelte
+ * <ContextMenuActions {actions}>
+ * <button>...</button>
+ * </ContextMenuActions>
+ * ```
+ */
+export { default as ContextMenuActions } from './ContextMenuActions.svelte';
/**
* **SidebarNavigation** - Sidebar with actions menu and conversation list
*
diff --git a/tools/ui/src/lib/components/ui/context-menu/context-menu-content.svelte b/tools/ui/src/lib/components/ui/context-menu/context-menu-content.svelte
new file mode 100644
index 000000000..28d82729c
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/context-menu/context-menu-content.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ import { cn } from '$lib/components/ui/utils.js';
+ import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
+
+ let {
+ class: className,
+ portalProps,
+ ref = $bindable(null),
+ ...restProps
+ }: ContextMenuPrimitive.ContentProps & {
+ portalProps?: ContextMenuPrimitive.PortalProps;
+ } = $props();
+</script>
+
+<ContextMenuPrimitive.Portal {...portalProps}>
+ <ContextMenuPrimitive.Content
+ bind:ref
+ class={cn(
+ 'z-50 max-h-[calc(var(--bits-context-menu-content-available-height)-1rem)] min-w-[8rem] origin-(--bits-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl border border-border bg-popover p-1.5 text-popover-foreground shadow-md outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
+ className
+ )}
+ data-slot="context-menu-content"
+ {...restProps}
+ />
+</ContextMenuPrimitive.Portal>
diff --git a/tools/ui/src/lib/components/ui/context-menu/context-menu-group.svelte b/tools/ui/src/lib/components/ui/context-menu/context-menu-group.svelte
new file mode 100644
index 000000000..0a5a14258
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/context-menu/context-menu-group.svelte
@@ -0,0 +1,7 @@
+<script lang="ts">
+ import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
+
+ let { ref = $bindable(null), ...restProps }: ContextMenuPrimitive.GroupProps = $props();
+</script>
+
+<ContextMenuPrimitive.Group bind:ref data-slot="context-menu-group" {...restProps} />
diff --git a/tools/ui/src/lib/components/ui/context-menu/context-menu-item.svelte b/tools/ui/src/lib/components/ui/context-menu/context-menu-item.svelte
new file mode 100644
index 000000000..cb7695111
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/context-menu/context-menu-item.svelte
@@ -0,0 +1,27 @@
+<script lang="ts">
+ import { cn } from '$lib/components/ui/utils.js';
+ import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
+
+ let {
+ class: className,
+ inset,
+ ref = $bindable(null),
+ variant = 'default',
+ ...restProps
+ }: ContextMenuPrimitive.ItemProps & {
+ inset?: boolean;
+ variant?: 'default' | 'destructive';
+ } = $props();
+</script>
+
+<ContextMenuPrimitive.Item
+ bind:ref
+ class={cn(
+ "relative flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 data-[variant=destructive]:data-highlighted:text-destructive dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:!text-destructive",
+ className
+ )}
+ data-inset={inset}
+ data-slot="context-menu-item"
+ data-variant={variant}
+ {...restProps}
+/>
diff --git a/tools/ui/src/lib/components/ui/context-menu/context-menu-label.svelte b/tools/ui/src/lib/components/ui/context-menu/context-menu-label.svelte
new file mode 100644
index 000000000..4366ec627
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/context-menu/context-menu-label.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ children,
+ class: className,
+ inset,
+ ref = $bindable(null),
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
+ inset?: boolean;
+ } = $props();
+</script>
+
+<div
+ bind:this={ref}
+ class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
+ data-inset={inset}
+ data-slot="context-menu-label"
+ {...restProps}
+>
+ {@render children?.()}
+</div>
diff --git a/tools/ui/src/lib/components/ui/context-menu/context-menu-separator.svelte b/tools/ui/src/lib/components/ui/context-menu/context-menu-separator.svelte
new file mode 100644
index 000000000..eb913abc4
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/context-menu/context-menu-separator.svelte
@@ -0,0 +1,17 @@
+<script lang="ts">
+ import { cn } from '$lib/components/ui/utils.js';
+ import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
+
+ let {
+ class: className,
+ ref = $bindable(null),
+ ...restProps
+ }: ContextMenuPrimitive.SeparatorProps = $props();
+</script>
+
+<ContextMenuPrimitive.Separator
+ bind:ref
+ class={cn('-mx-1 my-1 h-px bg-border/20', className)}
+ data-slot="context-menu-separator"
+ {...restProps}
+/>
diff --git a/tools/ui/src/lib/components/ui/context-menu/context-menu-shortcut.svelte b/tools/ui/src/lib/components/ui/context-menu/context-menu-shortcut.svelte
new file mode 100644
index 000000000..5974b06ac
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/context-menu/context-menu-shortcut.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ children,
+ class: className,
+ ref = $bindable(null),
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
+</script>
+
+<span
+ bind:this={ref}
+ class={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
+ data-slot="context-menu-shortcut"
+ {...restProps}
+>
+ {@render children?.()}
+</span>
diff --git a/tools/ui/src/lib/components/ui/context-menu/context-menu-sub-content.svelte b/tools/ui/src/lib/components/ui/context-menu/context-menu-sub-content.svelte
new file mode 100644
index 000000000..693a0a910
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/context-menu/context-menu-sub-content.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { cn } from '$lib/components/ui/utils.js';
+ import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
+
+ let {
+ class: className,
+ ref = $bindable(null),
+ ...restProps
+ }: ContextMenuPrimitive.SubContentProps = $props();
+</script>
+
+<ContextMenuPrimitive.SubContent
+ bind:ref
+ class={cn(
+ 'z-50 max-h-(--bits-menu-content-available-height) min-w-[8rem] origin-(--bits-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
+ className
+ )}
+ data-slot="context-menu-sub-content"
+ {...restProps}
+/>
diff --git a/tools/ui/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte b/tools/ui/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte
new file mode 100644
index 000000000..1249fb1e2
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
+ import { cn } from '$lib/components/ui/utils.js';
+ import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
+
+ let {
+ children,
+ class: className,
+ inset,
+ ref = $bindable(null),
+ ...restProps
+ }: ContextMenuPrimitive.SubTriggerProps & {
+ inset?: boolean;
+ } = $props();
+</script>
+
+<ContextMenuPrimitive.SubTrigger
+ bind:ref
+ class={cn(
+ "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
+ className
+ )}
+ data-inset={inset}
+ data-slot="context-menu-sub-trigger"
+ {...restProps}
+>
+ {@render children?.()}
+
+ <ChevronRightIcon class="ml-auto size-4" />
+</ContextMenuPrimitive.SubTrigger>
diff --git a/tools/ui/src/lib/components/ui/context-menu/context-menu-trigger.svelte b/tools/ui/src/lib/components/ui/context-menu/context-menu-trigger.svelte
new file mode 100644
index 000000000..063cd50d7
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/context-menu/context-menu-trigger.svelte
@@ -0,0 +1,7 @@
+<script lang="ts">
+ import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
+
+ let { ref = $bindable(null), ...restProps }: ContextMenuPrimitive.TriggerProps = $props();
+</script>
+
+<ContextMenuPrimitive.Trigger bind:ref data-slot="context-menu-trigger" {...restProps} />
diff --git a/tools/ui/src/lib/components/ui/context-menu/index.ts b/tools/ui/src/lib/components/ui/context-menu/index.ts
new file mode 100644
index 000000000..17b09a929
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/context-menu/index.ts
@@ -0,0 +1,37 @@
+import Content from './context-menu-content.svelte';
+import Group from './context-menu-group.svelte';
+import Item from './context-menu-item.svelte';
+import Label from './context-menu-label.svelte';
+import Separator from './context-menu-separator.svelte';
+import Shortcut from './context-menu-shortcut.svelte';
+import SubContent from './context-menu-sub-content.svelte';
+import SubTrigger from './context-menu-sub-trigger.svelte';
+import Trigger from './context-menu-trigger.svelte';
+import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
+const Sub = ContextMenuPrimitive.Sub;
+const Root = ContextMenuPrimitive.Root;
+
+export {
+ Content,
+ Root as ContextMenu,
+ Content as ContextMenuContent,
+ Group as ContextMenuGroup,
+ Item as ContextMenuItem,
+ Label as ContextMenuLabel,
+ Separator as ContextMenuSeparator,
+ Shortcut as ContextMenuShortcut,
+ Sub as ContextMenuSub,
+ SubContent as ContextMenuSubContent,
+ SubTrigger as ContextMenuSubTrigger,
+ Trigger as ContextMenuTrigger,
+ Group,
+ Item,
+ Label,
+ Root,
+ Separator,
+ Shortcut,
+ Sub,
+ SubContent,
+ SubTrigger,
+ Trigger
+};
diff --git a/tools/ui/src/lib/constants/keyboard-shortcuts.constants.ts b/tools/ui/src/lib/constants/keyboard-shortcuts.constants.ts
index 323147880..9511e9931 100644
--- a/tools/ui/src/lib/constants/keyboard-shortcuts.constants.ts
+++ b/tools/ui/src/lib/constants/keyboard-shortcuts.constants.ts
@@ -7,3 +7,4 @@ export const SHORTCUT_SETTINGS = ['cmd', ','];
export const SHORTCUT_EDIT_CONVERSATION = ['shift', 'cmd', 'e'];
export const SHORTCUT_EXPORT_CONVERSATION = ['shift', 'cmd', 's'];
export const SHORTCUT_DELETE_CONVERSATION = ['shift', 'cmd', 'd'];
+export const SHORTCUT_CLOSE_TAB = ['shift', 'cmd', 'x'];
diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts
index eed847c6c..bfe1fd9f0 100644
--- a/tools/ui/src/lib/constants/ui.constants.ts
+++ b/tools/ui/src/lib/constants/ui.constants.ts
@@ -17,6 +17,13 @@ export const COPY_FEEDBACK_RESET_MS = 1500;
/** How long a collapsible block's body takes to expand or collapse. */
export const COLLAPSIBLE_TRANSITION_MS = 200;
+/**
+ * Parts of a message where right-click keeps the browser's own menu (open or
+ * copy a link, save an image, copy code or a field), not the message actions.
+ */
+export const NATIVE_CONTEXT_MENU_SELECTOR =
+ 'a, img, video, audio, pre, code, input, textarea, [contenteditable="true"]';
+
export const FORK_TREE_DEPTH_PADDING = 8;
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts
index 348864c74..439b5f3ad 100644
--- a/tools/ui/src/lib/enums/index.ts
+++ b/tools/ui/src/lib/enums/index.ts
@@ -100,6 +100,7 @@ export {
ColorMode,
HtmlInputType,
McpPromptVariant,
+ MenuKind,
ScrollCarouselVariant,
SidebarAction,
TooltipSide,
diff --git a/tools/ui/src/lib/enums/ui.enums.ts b/tools/ui/src/lib/enums/ui.enums.ts
index 736d4e9d1..2f57cfc4c 100644
--- a/tools/ui/src/lib/enums/ui.enums.ts
+++ b/tools/ui/src/lib/enums/ui.enums.ts
@@ -63,3 +63,11 @@ export enum ColorLevel {
OK = 'ok',
WARNING = 'warning'
}
+
+/**
+ * Which menu an item's actions render in.
+ */
+export enum MenuKind {
+ CONTEXT = 'context',
+ DROPDOWN = 'dropdown'
+}
diff --git a/tools/ui/src/lib/services/tools.service.ts b/tools/ui/src/lib/services/tools.service.ts
index 78229756c..a4e8f0cac 100644
--- a/tools/ui/src/lib/services/tools.service.ts
+++ b/tools/ui/src/lib/services/tools.service.ts
@@ -6,10 +6,10 @@
*/
import { base } from '$app/paths';
-import { API_TOOLS, HEADERS } from '$lib/constants';
-import { ToolResponseField } from '$lib/enums';
-import type { ServerToolInfo, ToolExecutionResult } from '$lib/types';
-import { apiFetch } from '$lib/utils';
+import { API_TOOLS, HEADERS, HOME_TILDE, SEARCH } from '$lib/constants';
+import { BuiltInTool, GlobSearchType, ToolResponseField } from '$lib/enums';
+import type { GlobEntry, ServerToolInfo, ToolExecutionResult } from '$lib/types';
+import { apiFetch, buildCaseInsensitiveGlob, joinPath, lastPathSegment } from '$lib/utils';
import { getJsonHeaders } from '$lib/utils/api-headers';
import { parseSseJsonStream, type SseJsonEvent } from '$lib/utils/sse';
@@ -83,6 +83,35 @@ export class ToolsService {
return apiFetch<ServerToolInfo[]>(API_TOOLS.LIST);
}
+ /**
+ * Resolve a folder name picked in the browser (a native directory picker
+ * exposes only the leaf name) to a server-side absolute path. Returns null
+ * when the server cannot find it.
+ */
+ static async resolveDirectoryPath(
+ name: string,
+ homeBase: string | null = null
+ ): Promise<string | null> {
+ try {
+ const res = await ToolsService.executeToolRaw(BuiltInTool.SERVER_FILE_GLOB_SEARCH, {
+ include: buildCaseInsensitiveGlob(name),
+ limit: SEARCH.NATIVE_LIMIT,
+ max_depth: SEARCH.NATIVE_MAX_DEPTH,
+ path: homeBase ?? HOME_TILDE,
+ type: GlobSearchType.DIR
+ });
+ const base = typeof res.base === 'string' ? res.base : '';
+ const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
+ const match = entries.find(
+ (entry) => lastPathSegment(entry.path).toLowerCase() === name.toLowerCase()
+ );
+
+ return match ? joinPath(base, match.path) : null;
+ } catch {
+ return null;
+ }
+ }
+
/**
* Stream a server tool's output chunks from the server. The server
* `POST /tools` endpoint with `{stream: true}` emits `data: {"chunk": "..."}`
diff --git a/tools/ui/src/lib/stores/tabs.svelte.ts b/tools/ui/src/lib/stores/tabs.svelte.ts
index f21f84cc2..5e5c75fd8 100644
--- a/tools/ui/src/lib/stores/tabs.svelte.ts
+++ b/tools/ui/src/lib/stores/tabs.svelte.ts
@@ -92,6 +92,26 @@ export class TabsStore {
await this.close(id, true);
}
+ /**
+ * Close several tabs at once, e.g. the others or the ones to the right of
+ * a tab. When the active tab is among them, navigate to `keepId`, the tab
+ * the action was taken on.
+ * @param ids - Tab ids to close
+ * @param keepId - Tab that stays open
+ * @param activeId - Tab of the current route, if any
+ * @param view - Links of the tabs the view shows; all tabs when omitted
+ */
+ async closeMany(
+ ids: string[],
+ keepId: string,
+ activeId: string | null,
+ view: Pick<TabView, 'hrefFor'> = { hrefFor: (tabId) => this.hrefFor(tabId) }
+ ): Promise<void> {
+ this.removeTabs(ids);
+
+ if (activeId && ids.includes(activeId)) await goto(view.hrefFor(keepId));
+ }
+
hrefFor(id: string): string {
return id === NEW_CHAT_TAB_ID ? ROUTES.START : RouterService.chat(id);
}
diff --git a/tools/ui/src/lib/types/chat.d.ts b/tools/ui/src/lib/types/chat.d.ts
index 191af46d3..2ba74e9dc 100644
--- a/tools/ui/src/lib/types/chat.d.ts
+++ b/tools/ui/src/lib/types/chat.d.ts
@@ -302,10 +302,12 @@ export interface ChatMessageActionsContext {
readonly siblingInfo: ChatMessageSiblingInfo | null;
readonly deletionInfo: ChatMessageDeletionInfo | null;
readonly showDeleteDialog: boolean;
+ readonly showForkDialog: boolean;
copy: () => void;
requestDelete: () => void;
confirmDelete: () => void;
setShowDeleteDialog: (show: boolean) => void;
+ setShowForkDialog: (show: boolean) => void;
navigateToSibling: (siblingId: string) => void;
forkConversation?: (options: { name: string; includeAttachments: boolean }) => void;
}
diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts
index 4d9ae959a..ae3d68151 100644
--- a/tools/ui/src/lib/types/index.ts
+++ b/tools/ui/src/lib/types/index.ts
@@ -234,7 +234,7 @@ export type {
} from './agentic';
// Navigation types
-export type { DesktopIconStripItem } from './navigation';
+export type { DesktopIconStripItem, MenuAction } from './navigation';
// Tools types
export type {
diff --git a/tools/ui/src/lib/types/navigation.d.ts b/tools/ui/src/lib/types/navigation.d.ts
index af1e21d0a..d1c5840c7 100644
--- a/tools/ui/src/lib/types/navigation.d.ts
+++ b/tools/ui/src/lib/types/navigation.d.ts
@@ -15,3 +15,20 @@ export interface DesktopIconStripItem {
activeUrlIncludes?: string;
keys?: string[];
}
+
+/**
+ * One action in an item's menu, rendered the same way in its "more actions"
+ * dropdown and its right-click context menu.
+ */
+export interface MenuAction {
+ icon: Component;
+ label: string;
+ onclick: (event: Event) => void;
+ variant?: 'default' | 'destructive';
+ disabled?: boolean;
+ /** Shown on hover, e.g. why a disabled action is not available. */
+ tooltip?: string;
+ shortcut?: string[];
+ /** Draw a separator above this action. */
+ separator?: boolean;
+}