diff --git a/tools/ui/.gitignore b/tools/ui/.gitignore
index ddcfe2e60..0bb8c9b3c 100644
--- a/tools/ui/.gitignore
+++ b/tools/ui/.gitignore
@@ -28,10 +28,9 @@ vite.config.ts.timestamp-*
# PWA Artifacts
apple-splash-*.png
apple-touch-icon-*.png
-favicon.ico
-favicon-dark.ico
maskable-icon-*.png
pwa-*.png
+static/favicon*
# Storybook
*storybook.log
diff --git a/tools/ui/package-lock.json b/tools/ui/package-lock.json
index 9d0cdfea6..9dce3a0c9 100644
--- a/tools/ui/package-lock.json
+++ b/tools/ui/package-lock.json
@@ -35,7 +35,7 @@
"bits-ui": "2.18.1",
"clsx": "2.1.1",
"dexie": "4.4.3",
- "dompurify": "3.4.5",
+ "dompurify": "3.4.11",
"eslint": "9.39.4",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-storybook": "10.4.2",
@@ -8653,9 +8653,9 @@
"peer": true
},
"node_modules/dompurify": {
- "version": "3.4.5",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
- "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
+ "version": "3.4.11",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
+ "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"dev": true,
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
@@ -10226,9 +10226,9 @@
}
},
"node_modules/hono": {
- "version": "4.12.23",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
- "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
+ "version": "4.12.26",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz",
+ "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/tools/ui/package.json b/tools/ui/package.json
index 480392288..bcb4165d1 100644
--- a/tools/ui/package.json
+++ b/tools/ui/package.json
@@ -54,7 +54,7 @@
"bits-ui": "2.18.1",
"clsx": "2.1.1",
"dexie": "4.4.3",
- "dompurify": "3.4.5",
+ "dompurify": "3.4.11",
"eslint": "9.39.4",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-storybook": "10.4.2",
diff --git a/tools/ui/pwa-assets-dark.config.ts b/tools/ui/pwa-assets-dark.config.ts
index e9793bca9..358c0ebc0 100644
--- a/tools/ui/pwa-assets-dark.config.ts
+++ b/tools/ui/pwa-assets-dark.config.ts
@@ -1,4 +1,10 @@
import { defineConfig } from '@vite-pwa/assets-generator/config';
+import { FAVICON_COLORS, PWA_ASSET_GENERATOR } from './src/lib/constants/pwa';
+import { writeThemeFavicons } from './scripts/favicon-colorize';
+
+writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, {
+ padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
+});
export default defineConfig({
headLinkOptions: {
@@ -7,7 +13,8 @@ export default defineConfig({
preset: {
transparent: {
sizes: [],
- favicons: [[48, 'favicon-dark.ico']]
+ favicons: [[48, 'favicon-dark.ico']],
+ padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
},
maskable: {
sizes: []
diff --git a/tools/ui/pwa-assets.config.ts b/tools/ui/pwa-assets.config.ts
index 54928eeb4..b69884d94 100644
--- a/tools/ui/pwa-assets.config.ts
+++ b/tools/ui/pwa-assets.config.ts
@@ -5,15 +5,32 @@ import {
} from '@vite-pwa/assets-generator/config';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
-import { THEME_COLORS, PWA_GENERATOR_DEVICES, PWA_ASSET_GENERATOR } from './src/lib/constants/pwa';
+import {
+ THEME_COLORS,
+ PWA_GENERATOR_DEVICES,
+ PWA_ASSET_GENERATOR,
+ FAVICON_COLORS
+} from './src/lib/constants/pwa';
import { SplashOrientation } from './src/lib/enums/splash.enums';
+import { writeThemeFavicons } from './scripts/favicon-colorize';
+
+writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, {
+ padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
+});
export default defineConfig({
headLinkOptions: {
preset: PWA_ASSET_GENERATOR.LINK_PRESET
},
preset: combinePresetAndAppleSplashScreens(
- minimal2023Preset,
+ {
+ ...minimal2023Preset,
+ // tiny margin so favicon.ico / pwa-*.png breathe inside the canvas
+ transparent: {
+ ...minimal2023Preset.transparent,
+ padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
+ }
+ },
{
padding: PWA_ASSET_GENERATOR.SPLASH_PADDING,
resizeOptions: {
diff --git a/tools/ui/scripts/favicon-colorize.ts b/tools/ui/scripts/favicon-colorize.ts
new file mode 100644
index 000000000..e1872b777
--- /dev/null
+++ b/tools/ui/scripts/favicon-colorize.ts
@@ -0,0 +1,107 @@
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const PROJECT_ROOT = resolve(HERE, '..');
+
+const DEFAULT_LOGO = resolve(PROJECT_ROOT, 'src/lib/assets/logo.svg');
+const DEFAULT_OUT_DIR = resolve(PROJECT_ROOT, 'static');
+const DEFAULT_OUT_LIGHT = resolve(DEFAULT_OUT_DIR, 'favicon.svg');
+const DEFAULT_OUT_DARK = resolve(DEFAULT_OUT_DIR, 'favicon-dark.svg');
+
+const CURRENT_COLOR = 'currentColor';
+
+export interface ColorizedFavicon {
+ light: string;
+ dark: string;
+}
+
+export interface WriteThemeFaviconsOptions {
+ sourcePath?: string;
+ lightOutPath?: string;
+ darkOutPath?: string;
+ /**
+ * Fraction of the icon (0..1) to leave as an even margin on each side.
+ * Applied by wrapping the inner content in a `` so the
+ * source `src/lib/assets/logo.svg` is not modified. Pass 0 to disable.
+ */
+ padding?: number;
+}
+
+/**
+ * Replace every `currentColor` occurrence in the SVG with the given color.
+ * Pure: no filesystem access, so it is straightforward to unit-test.
+ */
+export function colorizeFaviconSvg(
+ svg: string,
+ lightColor: string,
+ darkColor: string
+): ColorizedFavicon {
+ return {
+ light: svg.replaceAll(CURRENT_COLOR, lightColor),
+ dark: svg.replaceAll(CURRENT_COLOR, darkColor)
+ };
+}
+
+/**
+ * Shrink the inner SVG content uniformly and re-center it so `padding` (a
+ * 0..1 fraction) is reserved as equal margin on each side. Returns the input
+ * unchanged for non-positive padding, missing/invalid `viewBox`, or unexpected
+ * markup so the caller always gets a renderable SVG.
+ */
+export function padFaviconSvg(svg: string, padding: number): string {
+ if (!(padding > 0) || padding >= 1) return svg;
+
+ const viewBoxMatch = svg.match(/viewBox\s*=\s*["']([^"']+)["']/i);
+ if (!viewBoxMatch) return svg;
+
+ const parts = viewBoxMatch[1]
+ .trim()
+ .split(/[\s,]+/)
+ .map(Number);
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return svg;
+
+ const [, , width, height] = parts;
+ if (width <= 0 || height <= 0) return svg;
+
+ const scale = 1 - padding;
+ const translateX = (padding * width) / 2;
+ const translateY = (padding * height) / 2;
+
+ const openTagStart = svg.search(/', openTagStart);
+ if (openTagEnd === -1) return svg;
+ const closeStart = svg.lastIndexOf(' `;
+ return `${openTag}${group}${inner} ${closeTag}`;
+}
+
+/**
+ * Read `src/lib/assets/logo.svg`, colorize it for both themes, and write
+ * the results to the static directory so the PWA asset generator can consume
+ * them. Paths can be overridden for tests.
+ */
+export function writeThemeFavicons(
+ lightColor: string,
+ darkColor: string,
+ {
+ sourcePath = DEFAULT_LOGO,
+ lightOutPath = DEFAULT_OUT_LIGHT,
+ darkOutPath = DEFAULT_OUT_DARK,
+ padding = 0
+ }: WriteThemeFaviconsOptions = {}
+): void {
+ const source = readFileSync(sourcePath, 'utf-8');
+ const { light, dark } = colorizeFaviconSvg(source, lightColor, darkColor);
+ mkdirSync(dirname(lightOutPath), { recursive: true });
+ writeFileSync(lightOutPath, padFaviconSvg(light, padding));
+ writeFileSync(darkOutPath, padFaviconSvg(dark, padding));
+}
diff --git a/tools/ui/src/app.css b/tools/ui/src/app.css
index 9254a96df..8c4056477 100644
--- a/tools/ui/src/app.css
+++ b/tools/ui/src/app.css
@@ -48,6 +48,7 @@
--chat-form-area-height: 8rem;
--chat-form-area-offset: 2rem;
+ --chat-form-padding-top: 6rem;
--max-message-height: max(24rem, min(80dvh, calc(100dvh - var(--chat-form-area-height) - 12rem)));
}
@@ -55,6 +56,7 @@
:root {
--chat-form-area-height: 24rem;
--chat-form-area-offset: 12rem;
+ --chat-form-padding-top: 6rem;
}
}
@@ -141,7 +143,6 @@
@apply bg-background text-foreground;
scrollbar-width: thin;
scrollbar-gutter: stable;
- overflow: hidden; /* Added due to Mermaid rendering somehow causing the double scrollbar */
}
/* Global scrollbar styling - visible only on hover */
@@ -193,3 +194,7 @@
scrollbar-width: none;
}
}
+
+.mermaidTooltip {
+ display: none !important;
+}
diff --git a/tools/ui/src/lib/actions/fade-in-view.svelte.ts b/tools/ui/src/lib/actions/fade-in-view.svelte.ts
index d93044805..9a5918131 100644
--- a/tools/ui/src/lib/actions/fade-in-view.svelte.ts
+++ b/tools/ui/src/lib/actions/fade-in-view.svelte.ts
@@ -10,9 +10,9 @@ import { isElementInViewport } from '$lib/utils/viewport';
*/
export function fadeInView(
node: HTMLElement,
- options: { duration?: number; y?: number; skipIfVisible?: boolean } = {}
+ options: { duration?: number; y?: number; delay?: number; skipIfVisible?: boolean } = {}
) {
- const { duration = 300, y = 0, skipIfVisible = false } = options;
+ const { duration = 300, y = 0, delay = 0, skipIfVisible = false } = options;
if (skipIfVisible && isElementInViewport(node)) {
return;
@@ -27,10 +27,12 @@ export function fadeInView(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
- requestAnimationFrame(() => {
- node.style.opacity = '1';
- node.style.transform = 'translateY(0)';
- });
+ setTimeout(() => {
+ requestAnimationFrame(() => {
+ node.style.opacity = '1';
+ node.style.transform = 'translateY(0)';
+ });
+ }, delay);
observer.disconnect();
}
}
diff --git a/tools/ui/src/lib/assets/logo.svg b/tools/ui/src/lib/assets/logo.svg
new file mode 100644
index 000000000..05424790a
--- /dev/null
+++ b/tools/ui/src/lib/assets/logo.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/tools/ui/src/lib/components/app/actions/ActionIcon.svelte b/tools/ui/src/lib/components/app/actions/ActionIcon.svelte
index f156df669..8a86557bb 100644
--- a/tools/ui/src/lib/components/app/actions/ActionIcon.svelte
+++ b/tools/ui/src/lib/components/app/actions/ActionIcon.svelte
@@ -8,12 +8,13 @@
ariaLabel?: string;
class?: string;
disabled?: boolean;
+ href?: string;
icon: Component;
iconSize?: string;
- onclick: (e?: MouseEvent) => void;
+ onclick?: (e?: MouseEvent) => void;
size?: ButtonSize;
stopPropagationOnClick?: boolean;
- tooltip: string;
+ tooltip?: string;
variant?: ButtonVariant;
tooltipSide?: TooltipSide;
}
@@ -22,6 +23,7 @@
icon,
tooltip,
variant = 'ghost',
+ href = '',
size = 'sm',
class: className = '',
disabled = false,
@@ -31,34 +33,49 @@
onclick,
ariaLabel
}: Props = $props();
+
+ let innerWidth = $state(0);
+ const showTooltip = $derived(!!tooltip && innerWidth > 768);
-
-
-
- {#snippet child({ props })}
- {
- if (stopPropagationOnClick) e.stopPropagation();
+{#snippet button(props = {})}
+ {
+ if (stopPropagationOnClick) e.stopPropagation();
- onclick?.(e);
- }}
- class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
- aria-label={ariaLabel || tooltip}
- >
- {#if icon}
- {@const IconComponent = icon}
-
- {/if}
-
- {/snippet}
-
+ onclick?.(e);
+ }}
+ class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
+ aria-label={ariaLabel || tooltip}
+ >
+ {#if icon}
+ {@const IconComponent = icon}
-
- {tooltip}
-
-
+
+ {/if}
+
+{/snippet}
+
+{#if showTooltip}
+
+
+
+ {#snippet child({ props })}
+ {@render button(props)}
+ {/snippet}
+
+
+
+ {tooltip}
+
+
+{:else}
+ {@render button({ href })}
+{/if}
+
+
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
index ed26f9ea5..9b2077b8d 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
@@ -494,7 +494,7 @@
/>
{/if}
-
+ attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
+ >
System Message
{#if hasMcpPromptsSupport}
-
+ attachmentMenu.callbacks[AttachmentAction.MCP_PROMPT_CLICK]()}
+ >
MCP Prompt
@@ -285,7 +294,11 @@
{/if}
{#if hasMcpResourcesSupport}
-
+ attachmentMenu.callbacks[AttachmentAction.MCP_RESOURCES_CLICK]()}
+ >
MCP Resources
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte
index 6a91bf905..08d691c14 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte
@@ -42,6 +42,7 @@
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
{onFileUpload}
+ {onSystemPromptClick}
{onMcpPromptClick}
{onMcpResourcesClick}
>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte
index 8774bf63a..eff0364fa 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte
@@ -20,7 +20,7 @@
type="submit"
disabled={isDisabled}
class={[
- 'h-8 w-8 rounded-full p-0',
+ 'md:h-8 md:w-8 h-9 w-9 rounded-full p-0',
showErrorState &&
'bg-red-400/10 text-red-400 hover:bg-red-400/20 hover:text-red-400 disabled:opacity-100'
]}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte
index 72e62f319..3e683389f 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte
@@ -1,4 +1,5 @@
-
+
{#if message.role === MessageRole.SYSTEM}
{
+ if (!assistantEl) return;
+
+ assistantMarginTop = Math.round(parseFloat(getComputedStyle(assistantEl).marginTop));
+
+ const chatMessageEl = assistantEl.closest('.chat-message');
+ const previousChatMessage = chatMessageEl?.previousElementSibling;
+ const userMessageEl = previousChatMessage?.querySelector(
+ '.chat-message-user'
+ ) as HTMLElement | null;
+
+ if (!userMessageEl) {
+ lastUserMessageHeight = 0;
+ return;
+ }
+
+ const updateHeight = () => {
+ const rect = userMessageEl.getBoundingClientRect();
+ const marginTop = Math.round(parseFloat(getComputedStyle(userMessageEl).marginTop));
+ lastUserMessageHeight = Math.round(rect.height + marginTop);
+ };
+
+ updateHeight();
+
+ const resizeObserver = new ResizeObserver(updateHeight);
+ resizeObserver.observe(userMessageEl);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ });
+
function handleCopyModel() {
void copyToClipboard(displayedModel ?? '');
}
@@ -219,12 +255,17 @@
0
+ ? `${lastUserMessageHeight}px`
+ : undefined}
+ style:--assistant-margin-top={assistantMarginTop > 0 ? `${assistantMarginTop}px` : undefined}
role="group"
aria-label="Assistant message with actions"
>
{#if showProcessingInfoTop}
-
+
{modelLoadingText ??
@@ -257,7 +298,7 @@
{/if}
{#if showProcessingInfoBottom}
-
+
{modelLoadingText ??
@@ -351,6 +392,23 @@
diff --git a/tools/ui/src/lib/components/app/misc/index.ts b/tools/ui/src/lib/components/app/misc/index.ts
index 64b76fb71..b550ae66a 100644
--- a/tools/ui/src/lib/components/app/misc/index.ts
+++ b/tools/ui/src/lib/components/app/misc/index.ts
@@ -51,3 +51,11 @@ export { default as KeyboardShortcutInfo } from './KeyboardShortcutInfo.svelte';
* Preview button is shown only for HTML code blocks.
*/
export { default as CodeBlockActions } from './CodeBlockActions.svelte';
+
+/**
+ * **Logo** - Application brand mark
+ *
+ * Inline SVG of the application logo. Accepts styling via the standard
+ * `class` and `style` props and inherits color via `currentColor`.
+ */
+export { default as Logo } from './Logo.svelte';
diff --git a/tools/ui/src/lib/components/app/navigation/DesktopIconStrip.svelte b/tools/ui/src/lib/components/app/navigation/DesktopIconStrip.svelte
deleted file mode 100644
index e92b9528a..000000000
--- a/tools/ui/src/lib/components/app/navigation/DesktopIconStrip.svelte
+++ /dev/null
@@ -1,84 +0,0 @@
-
-
-
-
-
-
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 1fa7722f2..1445961e0 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
@@ -1,40 +1,67 @@
-
-
-