<script lang="ts">
import { goto } from '$app/navigation';
+ import { base } from '$app/paths';
import {
chatStore,
pendingEditMessageId,
const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
if (conversationDeleted) {
- goto('/');
+ goto(`${base}/`);
}
return;
const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
isEditing = false;
if (conversationDeleted) {
- goto('/');
+ goto(`${base}/`);
}
return;
}
import { BadgeChatStatistic } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ChatMessageStatsView } from '$lib/enums';
+ import { formatPerformanceTime } from '$lib/utils/formatters';
interface Props {
predictedTokens?: number;
);
let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0);
- let timeInSeconds = $derived(
- predictedMs !== undefined ? (predictedMs / 1000).toFixed(2) : '0.00'
+ let formattedTime = $derived(
+ predictedMs !== undefined ? formatPerformanceTime(predictedMs) : '0s'
);
let promptTokensPerSecond = $derived(
: undefined
);
- let promptTimeInSeconds = $derived(
- promptMs !== undefined ? (promptMs / 1000).toFixed(2) : undefined
+ let formattedPromptTime = $derived(
+ promptMs !== undefined ? formatPerformanceTime(promptMs) : undefined
);
let hasPromptStats = $derived(
promptTokens !== undefined &&
promptMs !== undefined &&
promptTokensPerSecond !== undefined &&
- promptTimeInSeconds !== undefined
+ formattedPromptTime !== undefined
);
// In live mode, generation tab is disabled until we have generation stats
<BadgeChatStatistic
class="bg-transparent"
icon={Clock}
- value="{timeInSeconds}s"
+ value={formattedTime}
tooltipLabel="Generation time"
/>
<BadgeChatStatistic
<BadgeChatStatistic
class="bg-transparent"
icon={Clock}
- value="{promptTimeInSeconds}s"
+ value={formattedPromptTime ?? '0s'}
tooltipLabel="Prompt processing time"
/>
<BadgeChatStatistic
--- /dev/null
+<script lang="ts">
+ import type { Snippet } from 'svelte';
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+ import { cn } from '$lib/components/ui/utils';
+ import { SearchInput } from '$lib/components/app';
+
+ interface Props {
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ placeholder?: string;
+ searchValue?: string;
+ onSearchChange?: (value: string) => void;
+ onSearchKeyDown?: (event: KeyboardEvent) => void;
+ align?: 'start' | 'center' | 'end';
+ contentClass?: string;
+ emptyMessage?: string;
+ isEmpty?: boolean;
+ disabled?: boolean;
+ trigger: Snippet;
+ children: Snippet;
+ footer?: Snippet;
+ }
+
+ let {
+ open = $bindable(false),
+ onOpenChange,
+ placeholder = 'Search...',
+ searchValue = $bindable(''),
+ onSearchChange,
+ onSearchKeyDown,
+ align = 'start',
+ contentClass = 'w-72',
+ emptyMessage = 'No items found',
+ isEmpty = false,
+ disabled = false,
+ trigger,
+ children,
+ footer
+ }: Props = $props();
+
+ function handleOpenChange(newOpen: boolean) {
+ open = newOpen;
+
+ if (!newOpen) {
+ searchValue = '';
+ onSearchChange?.('');
+ }
+
+ onOpenChange?.(newOpen);
+ }
+</script>
+
+<DropdownMenu.Root bind:open onOpenChange={handleOpenChange}>
+ <DropdownMenu.Trigger
+ {disabled}
+ onclick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+ {@render trigger()}
+ </DropdownMenu.Trigger>
+
+ <DropdownMenu.Content {align} class={cn(contentClass, 'pt-0')}>
+ <div class="sticky top-0 z-10 mb-2 bg-popover p-1 pt-2">
+ <SearchInput
+ {placeholder}
+ bind:value={searchValue}
+ onInput={onSearchChange}
+ onKeyDown={onSearchKeyDown}
+ />
+ </div>
+
+ <div class={cn('overflow-y-auto')}>
+ {@render children()}
+
+ {#if isEmpty}
+ <div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
+ {/if}
+ </div>
+
+ {#if footer}
+ <DropdownMenu.Separator />
+
+ {@render footer()}
+ {/if}
+ </DropdownMenu.Content>
+</DropdownMenu.Root>
text-decoration: underline;
text-underline-offset: 2px;
transition: color 0.2s ease;
+ overflow-wrap: anywhere;
+ word-break: break-all;
}
div :global(a:hover) {
return num.toLocaleString();
}
+
+/**
+ * Format JSON string with pretty printing (2-space indentation)
+ * Returns original string if parsing fails
+ *
+ * @param jsonString - JSON string to format
+ * @returns Pretty-printed JSON string or original if invalid
+ */
+export function formatJsonPretty(jsonString: string): string {
+ try {
+ const parsed = JSON.parse(jsonString);
+ return JSON.stringify(parsed, null, 2);
+ } catch {
+ return jsonString;
+ }
+}
+
+/**
+ * Format time as HH:MM:SS in 24-hour format
+ *
+ * @param date - Date object to format
+ * @returns Formatted time string (HH:MM:SS)
+ */
+export function formatTime(date: Date): string {
+ return date.toLocaleTimeString('en-US', {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit'
+ });
+}
+
+/**
+ * Formats milliseconds to a human-readable time string for performance metrics.
+ * Examples: "4h 12min 54s", "12min 34s", "45s", "0.5s"
+ *
+ * @param ms - Time in milliseconds
+ * @returns Formatted time string
+ */
+export function formatPerformanceTime(ms: number): string {
+ if (ms < 0) return '0s';
+
+ const totalSeconds = ms / 1000;
+
+ if (totalSeconds < 1) {
+ return `${totalSeconds.toFixed(1)}s`;
+ }
+
+ if (totalSeconds < 10) {
+ return `${totalSeconds.toFixed(1)}s`;
+ }
+
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = Math.floor(totalSeconds % 60);
+
+ const parts: string[] = [];
+
+ if (hours > 0) {
+ parts.push(`${hours}h`);
+ }
+
+ if (minutes > 0) {
+ parts.push(`${minutes}min`);
+ }
+
+ if (seconds > 0 || parts.length === 0) {
+ parts.push(`${seconds}s`);
+ }
+
+ return parts.join(' ');
+}
import { defineMeta } from '@storybook/addon-svelte-csf';
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
import { expect } from 'storybook/test';
- import { mockServerProps, mockConfigs } from './fixtures/storybook-mocks';
import jpgAsset from './fixtures/assets/1.jpg?url';
import svgAsset from './fixtures/assets/hf-logo.svg?url';
import pdfAsset from './fixtures/assets/example.pdf?raw';
name="Default"
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
play={async ({ canvas, userEvent }) => {
- mockServerProps(mockConfigs.noModalities);
-
const textarea = await canvas.findByRole('textbox');
const submitButton = await canvas.findByRole('button', { name: 'Send' });
const fileInput = document.querySelector('input[type="file"]');
await expect(fileInput).not.toHaveAttribute('accept');
-
- // Open file attachments dropdown
- const fileUploadButton = canvas.getByText('Attach files');
- await userEvent.click(fileUploadButton);
-
- // Check dropdown menu items are disabled (no modalities)
- const imagesButton = document.querySelector('.images-button');
- const audioButton = document.querySelector('.audio-button');
-
- await expect(imagesButton).toHaveAttribute('data-disabled');
- await expect(audioButton).toHaveAttribute('data-disabled');
-
- // Close dropdown by pressing Escape
- await userEvent.keyboard('{Escape}');
}}
/>
<Story name="Loading" args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', isLoading: true }} />
-<Story
- name="VisionModality"
- args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
- play={async ({ canvas, userEvent }) => {
- mockServerProps(mockConfigs.visionOnly);
-
- // Open file attachments dropdown and verify it works
- const fileUploadButton = canvas.getByText('Attach files');
- await userEvent.click(fileUploadButton);
-
- // Verify dropdown menu items exist
- const imagesButton = document.querySelector('.images-button');
- const audioButton = document.querySelector('.audio-button');
-
- await expect(imagesButton).toBeInTheDocument();
- await expect(audioButton).toBeInTheDocument();
-
- // Close dropdown by pressing Escape
- await userEvent.keyboard('{Escape}');
-
- console.log('✅ Vision modality: Dropdown menu verified');
- }}
-/>
-
-<Story
- name="AudioModality"
- args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
- play={async ({ canvas, userEvent }) => {
- mockServerProps(mockConfigs.audioOnly);
-
- // Open file attachments dropdown and verify it works
- const fileUploadButton = canvas.getByText('Attach files');
- await userEvent.click(fileUploadButton);
-
- // Verify dropdown menu items exist
- const imagesButton = document.querySelector('.images-button');
- const audioButton = document.querySelector('.audio-button');
-
- await expect(imagesButton).toBeInTheDocument();
- await expect(audioButton).toBeInTheDocument();
-
- // Close dropdown by pressing Escape
- await userEvent.keyboard('{Escape}');
-
- console.log('✅ Audio modality: Dropdown menu verified');
- }}
-/>
-
<Story
name="FileAttachments"
args={{
uploadedFiles: fileAttachments
}}
play={async ({ canvas }) => {
- mockServerProps(mockConfigs.bothModalities);
-
const jpgAttachment = canvas.getByAltText('1.jpg');
const svgAttachment = canvas.getByAltText('hf-logo.svg');
const pdfFileExtension = canvas.getByText('PDF');