-
Notifications
You must be signed in to change notification settings - Fork 0
20. User Interface Guide
Changes Made
- Updated CodeMirror Integration section with enhanced accessibility features and responsive design improvements
- Added detailed information about language detection and syntax highlighting in CodeMirror integration
- Enhanced description of markdown rendering with improved security and feature detection
- Updated styling details for CodeMirror toolbar and copy button animations
- Added information about mobile-responsive adjustments for code blocks
- Updated section sources to reflect changes in codemirror-renderer.ts, markdown.ts, codemirror.css, and markdown.css
- Removed references to the deprecated LoadButton component and updated model loading section to reflect current implementation
- Updated model loading interface description to reflect removal of LoadButton component
- Added new section on Application Version Display in Sidebar to document the dynamic version display feature
- Updated section sources to include Sidebar.svelte and app.d.ts files
- Added new section on Messages Toolbar with Sidebar Toggle Buttons to document the new toolbar implementation
- Added new section on Custom Window Dragging Implementation to document the header-based window dragging functionality
- Updated section sources to include MessageList.svelte and +layout.svelte files
- Chat Interface Overview
- Message History Display
- Prompt Input Field and Send Button
- Streaming Response Rendering
- Thinking Mode Visualization
- Model Loading Progress and Device Status
- Parameter Controls
- Responsive Design Implementation
- User Interaction Flow
- Accessibility and Keyboard Navigation
- CodeMirror Integration
- Application Version Display in Sidebar
- Messages Toolbar with Sidebar Toggle Buttons
- Custom Window Dragging Implementation
The chat interface is implemented using Svelte components with CSS modules for styling. The UI is divided into two main sections: the chat area and the model loader panel. On desktop (960px and above), these panels are displayed side-by-side using a flex layout to ensure equal heights. The interface supports both light and dark color schemes, with appropriate styling adjustments based on the user's system preferences.
The chat interface consists of several key components:
- Message history display area
- Prompt input field with action buttons
- Model loading and configuration panel
- Streaming response renderer with Markdown support
- Thinking Mode visualization for intermediate reasoning steps
- CodeMirror integration for enhanced code block rendering with syntax highlighting and copy functionality
- Messages toolbar with sidebar toggle buttons
- Custom window dragging implementation via the application header
Section sources
- base.css
- chat.css
The message history is displayed in a scrollable container with a clean, modern design. Messages are organized in a vertical stack, with user messages aligned to the right and assistant messages to the left. Each message appears in a bubble with rounded corners and appropriate background coloring.
.messages {
margin-top: 6px;
background: #faf9f7;
border: 1px dashed #e8e6e3;
border-radius: 10px;
padding: 12px;
flex: 1 1 auto;
overflow: auto;
display: flex;
flex-direction: column;
gap: 8px;
color: var(--text);
}
.message { display: flex; }
.message.user { justify-content: flex-end; }
.message.assistant { justify-content: flex-start; }
.bubble {
max-width: 80%;
padding: 10px 12px;
border-radius: 12px;
background: #eae6f8;
color: var(--text);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.message.user .bubble { background: var(--accent-2); }
.message.assistant .bubble { background: #e8f0f8; }Messages are structured as objects with a role and content property:
export type ChatMessage = {
role: "user" | "assistant";
content: string;
html?: string;
};When a new message is added, it is pushed to the messages array in the controller context, which triggers a re-render of the message history.
Section sources
- chat.css
- types.ts
The prompt input field is implemented as a textarea within a composer component that includes action buttons for sending messages and controlling audio input. The input field supports multi-line text entry and automatically resizes to accommodate content.
.composer {
display: flex;
gap: 8px;
margin-top: 8px;
position: relative;
}
.composer textarea {
flex: 1;
border: 1px solid #e8e6e3;
background: #fcfbfa;
color: var(--text);
border-radius: 10px;
padding: 10px 12px;
padding-right: 60px;
outline: none;
resize: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.composer textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(179, 205, 224, 0.1);
}The send button is positioned absolutely in the top-right corner of the input field, creating a clean, modern interface. When clicked, it triggers the handleSend function in the controller.
async function handleSend() {
const text = ctx.prompt.trim();
if (!text || ctx.busy) return;
if (!ctx.isLoaded) {
await message("Load model and tokenizer first", { title: "Model not loaded", kind: "warning" });
return;
}
const textUi = text.replace(/^\s*\/(?:think|no_think)\b[ \t]*/i, "");
const msgs = ctx.messages;
msgs.push({ role: "user", content: textUi } as any);
msgs.push({ role: "assistant", content: "" } as any);
ctx.messages = msgs;
ctx.prompt = "";
queueMicrotask(() => {
const c = ctx.messagesEl;
if (!c) return;
const atBottom = c.scrollTop + c.clientHeight >= c.scrollHeight - 32;
if (atBottom) c.scrollTop = c.scrollHeight;
});
await generateFromHistory();
}The send button has a visual feedback effect with a wave animation when clicked:
.send-btn { background: var(--accent-2); }
.send-btn:hover:not(:disabled) { background: #d4b8ff; }
.send-btn:not(:disabled)::before {
content: '';
position: absolute;
top: 0; left: -100%; width: 100%; height: 100%;
background: rgba(255, 255, 255, 0.2);
transition: left 0.5s ease;
}
.send-btn:not(:disabled):hover::before { left: 100%; }Section sources
- actions.ts
- chat.css
The streaming response rendering system processes incoming tokens from the model and displays them in real-time. The system uses a segmented approach to handle both plain text and HTML content, allowing for rich formatting in responses.
The rendering pipeline consists of several components that work together:
mermaid
flowchart TD
A[Incoming Stream Data] --> B{Parse Stream}
B --> C[Text Segment]
B --> D[HTML Segment]
C --> E[Ensure Markdown Container]
C --> F[Append Markdown Text]
D --> G[Append Think-Aware HTML]
E --> H[Update Message Content]
F --> H
G --> H
H --> I[Render to DOM]
Diagram sources
- render_impl.ts
- listener.ts
The appendSegments function processes incoming stream segments and routes them to the appropriate rendering function:
export function appendSegments(index: number, bubble: HTMLDivElement, segments: StreamSegment[]) {
let ctx = (bubbleCtxs.get(index) ?? {
inThink: false,
thinkPre: null,
thinkSummary: null,
thinkCaretHost: null,
thinkBrainHost: null,
mdEl: null,
mdText: "",
lastKind: null
}) as any;
for (const seg of segments) {
if (seg.kind === "html") {
appendThinkAwareHtml(ctx, bubble, seg.data);
ctx.lastKind = "html";
} else {
ctx = ensureMarkdownContainer(ctx, bubble);
ctx = appendMarkdownText(ctx, seg.data);
ctx.lastKind = "text";
}
}
bubbleCtxs.set(index, ctx);
}Each assistant message bubble maintains its own rendering context, stored in the bubbleCtxs map, which tracks the current state of the rendering process, including whether the system is in Thinking Mode and the current Markdown rendering state.
Section sources
- render_impl.ts
- bubble_ctx.ts
The Thinking Mode visualization displays intermediate reasoning steps with HTML rendering, allowing users to see the model's thought process before delivering a final response. This feature is implemented in the think_html.ts file using a custom HTML parsing system that detects special markers in the stream.
mermaid
flowchart TD
A[Incoming HTML Stream] --> B{In Thinking Mode?}
B --> |No| C{Contains THINK_OPEN marker?}
B --> |Yes| D{Contains THINK_CLOSE marker?}
C --> |Yes| E[Enter Thinking Mode]
C --> |No| F[Render as regular HTML]
D --> |Yes| G[Exit Thinking Mode]
D --> |No| H[Append to current thinking block]
E --> I[Create details element]
E --> J[Mount Brain and Caret icons]
I --> K[Render to DOM]
H --> K
G --> K
F --> K
Diagram sources
- think_html.ts
- bubble_ctx.ts
The Thinking Mode is triggered by specific HTML markers in the stream:
const THINK_OPEN_EXACT =
'<details class="cot"><summary><span class="brain-host"></span><span class="cot-label">Reasoning</span><span class="caret-host"></span></summary><pre class="cot-pre">';
const THINK_CLOSE = "</pre></details>";When the system detects the opening marker, it creates a collapsible details element with a brain icon and "Reasoning" label. The Svelte component system mounts the Brain and CaretRight icons from the Phosphor icon library:
if (ctx.thinkCaretHost) {
ctx.thinkCaretIcon = mount(CaretRight, { target: ctx.thinkCaretHost as HTMLElement, props: { size: 16, weight: "bold" } });
}
if (ctx.thinkBrainHost) {
ctx.thinkBrainIcon = mount(Brain, { target: ctx.thinkBrainHost as HTMLElement, props: { size: 16, weight: "regular" } });
}The Thinking Mode content is displayed in a pre-formatted block with a distinct background color, making it visually separate from the final response. Users can collapse or expand the reasoning steps by clicking on the summary element.
Section sources
- think_html.ts
- markdown.css
The model loading interface provides visual feedback on the loading process, including progress bars and status messages. The system displays different stages of loading (model, tokenizer, complete) with appropriate progress indicators.
.loading-status {
margin-top: 12px;
padding: 16px;
background: var(--panel-alt-bg);
border-radius: 8px;
border: 1px solid var(--border-color);
animation: slideDown 0.3s ease-out;
transition: all 0.3s ease;
}
.loading-progress-bar {
width: 100%;
height: 6px;
background: var(--panel-bg);
border-radius: 3px;
overflow: hidden;
position: relative;
transition: all 0.3s ease;
margin-bottom: 16px;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}The device status is displayed using a toggle switch that shows whether the system is using CPU or GPU:
.switch { position: relative; display: inline-block; width: 48px; height: 26px; }
.slider {
position: absolute; cursor: default; top: 0; left: 0; right: 0; bottom: 0;
background: #ccc; transition: background-color 0.2s ease; border-radius: 26px;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
}
.slider:before {
position: absolute; content: ""; height: 22px; width: 22px;
left: 2px; top: 2px; background: white; transition: transform 0.2s ease;
border-radius: 50%; box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.switch input:checked + .slider { background: linear-gradient(135deg, var(--accent-2), #d4b8ff); }
.switch input:checked + .slider:before { transform: translateX(22px); }The controller manages the device status and loading process:
async function refreshDeviceInfo() {
try {
const info = await invoke<any>("get_device_info");
ctx.cuda_build = !!info?.cuda_build;
ctx.cuda_available = !!info?.cuda_available;
ctx.current_device = String(info?.current ?? "CPU");
ctx.use_gpu = ctx.cuda_available && ctx.current_device === "CUDA";
} catch {}
}
async function loadGGUF() {
ctx.isLoadingModel = true;
ctx.loadingProgress = 0;
ctx.loadingStage = "model";
ctx.busy = true;
// ... loading logic with progress updates
}Section sources
- actions.ts
- progress.css
The parameter controls allow users to adjust various inference parameters such as temperature, top-k, top-p, and repetition penalty. These controls are organized in a structured layout with checkboxes to enable/disable custom parameters and input fields to set their values.
.params { display: grid; gap: 10px; margin-top: 12px; }
.param { display: grid; gap: 6px; }
.param .head {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 8px;
align-items: center;
}
.param .head label { font-size: 13px; color: var(--text); }
.param .head .value-input {
width: 112px;
}
.param .slider-row {
display: grid;
}
.param .slider-row input[type="range"] {
width: 100%;
}The controller context exposes these parameters as reactive properties:
export type ChatControllerCtx = {
// ... other properties
get temperature(): number; set temperature(v: number);
get temperature_enabled(): boolean; set temperature_enabled(v: boolean);
get top_k_enabled(): boolean; set top_k_enabled(v: boolean);
get top_k_value(): number; set top_k_value(v: number);
get top_p_enabled(): boolean; set top_p_enabled(v: boolean);
get top_p_value(): number; set top_p_value(v: number);
get min_p_enabled(): boolean; set min_p_enabled(v: boolean);
get min_p_value(): number; set min_p_value(v: number);
get repeat_penalty_enabled(): boolean; set repeat_penalty_enabled(v: boolean);
get repeat_penalty_value(): number; set repeat_penalty_value(v: number);
get ctx_limit_value(): number; set ctx_limit_value(v: number);
get enable_thinking(): boolean; set enable_thinking(v: boolean);
get use_custom_params(): boolean; set use_custom_params(v: boolean);
// ... other properties
};When generating a response, the system includes these parameters in the request if they are enabled:
await invoke("generate_stream", {
req: {
prompt: chatPrompt,
use_custom_params: ctx.use_custom_params,
temperature: ctx.use_custom_params && ctx.temperature_enabled ? ctx.temperature : null,
top_p: ctx.use_custom_params && ctx.top_p_enabled ? (ctx.top_p_value > 0 && ctx.top_p_value <= 1 ? ctx.top_p_value : 0.9) : null,
top_k: ctx.use_custom_params && ctx.top_k_enabled ? Math.max(1, Math.floor(ctx.top_k_value)) : null,
min_p: ctx.use_custom_params && ctx.min_p_enabled ? (ctx.min_p_value > 0 && ctx.min_p_value <= 1 ? ctx.min_p_value : 0.05) : null,
repeat_penalty: ctx.use_custom_params && ctx.repeat_penalty_enabled ? ctx.repeat_penalty_value : null,
repeat_last_n: 64,
},
});Section sources
- actions.ts
- types.ts
- base.css
The responsive design is implemented using CSS media queries and flexbox layout. The interface adapts to different screen sizes, providing an optimal user experience on both mobile and desktop devices.
On mobile devices (below 960px), the interface uses a stacked layout with the chat area on top and the loader panel below:
.wrap {
min-height: 0;
background: none;
color: var(--text);
display: grid;
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) auto;
gap: 16px;
padding: 16px;
width: 100%;
margin: 0;
box-sizing: border-box;
}On desktop devices (960px and above), the interface switches to a side-by-side layout:
@media (min-width: 960px) {
.wrap {
display: flex;
gap: var(--content-gap);
height: 100%;
box-sizing: border-box;
align-items: stretch;
}
.chat {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.loader {
flex: 0 0 360px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: auto;
}
}The design ensures that both panels have equal height on desktop by using align-items: stretch on the flex container. The chat area is set to flex: 1 1 auto to allow it to grow and fill available space, while the loader panel has a fixed width of 360px.
The interface also supports dark mode through CSS media queries:
@media (prefers-color-scheme: dark) {
.messages { background: #222; border-color: #333; }
.message.user .bubble { background: #4a3f66; color: #eee; }
.message.assistant .bubble { background: #2f3640; color: #eee; }
.composer textarea { background: #2d2d2d; border-color: #3a3a3a; color: #eee; }
/* ... other dark mode styles */
}Section sources
- base.css
- chat.css
The user interaction flow is managed through the controller system, which handles all user actions and communicates with the Tauri backend via the invoke API. The flow begins when the user interacts with the interface and ends when the response is fully rendered.
mermaid
sequenceDiagram
participant User as "User"
participant UI as "UI Component"
participant Controller as "ChatController"
participant Tauri as "Tauri Backend"
User->>UI : Type message and click Send
UI->>Controller : handleSend()
Controller->>Controller : Validate input and model state
Controller->>Controller : Add user message to history
Controller->>Controller : Scroll to bottom
Controller->>Controller : generateFromHistory()
Controller->>Tauri : invoke("generate_stream")
Tauri->>Tauri : Process request and stream response
Tauri->>Controller : Stream data events
Controller->>Controller : parse stream data
Controller->>Controller : appendSegments()
Controller->>UI : Update DOM with new content
loop Stream Processing
Tauri->>Controller : Stream data events
Controller->>Controller : parse and render segments
Controller->>UI : Update DOM incrementally
end
Controller->>UI : Complete response rendering
Diagram sources
- actions.ts
- listener.ts
The controller uses the Tauri invoke API to communicate with the Rust backend:
import { invoke } from "@tauri-apps/api/core";
async function handleSend() {
// ... validation and message preparation
await generateFromHistory();
}
async function generateFromHistory() {
// ... prepare request
await invoke("generate_stream", {
req: {
prompt: chatPrompt,
// ... parameters
},
});
}The system listens for stream events from the backend and processes them in real-time:
export function createStreamListener(ctx: ChatControllerCtx) {
let unlisten: UnlistenFn | null = null;
let streamBuf = "";
let rafId: number | null = null;
const streamParser = createStreamParser();
function scheduleFlush() {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
const { segments, remainder } = streamParser.parse(streamBuf);
// ... process segments and update UI
});
}
// Set up event listener
void listen("stream-data", (event) => {
streamBuf += event.payload as string;
scheduleFlush();
});
}Section sources
- actions.ts
- listener.ts
The interface includes several accessibility features to ensure it can be used by all users, including those who rely on keyboard navigation or screen readers.
Keyboard navigation is supported through standard HTML form controls and proper focus management. The prompt input field can be focused with Tab navigation, and the send button can be activated with Enter when the input field is focused.
// The textarea naturally supports keyboard navigation
// and the send button is positioned for easy accessARIA attributes are used to enhance accessibility:
/* The details/summary elements for Thinking Mode are natively accessible */
.cot > summary {
cursor: default;
color: var(--accent);
font-weight: 600;
display: inline-flex;
align-items: baseline;
gap: 8px;
list-style: none;
padding: 4px 0;
user-select: none;
}
/* Form controls have appropriate labels */
.field label { font-size: 12px; color: var(--muted); }
.param .head label { font-size: 13px; color: var(--text); }The interface also includes visual indicators for interactive elements:
.composer textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(179, 205, 224, 0.1);
}
.send-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}Screen reader users can navigate the message history as a list of items, with user and assistant messages clearly distinguished by their roles. The Thinking Mode content is presented as a collapsible section with an appropriate label ("Reasoning") that screen readers can announce.
The color contrast meets accessibility standards in both light and dark modes, ensuring readability for users with visual impairments. The interface avoids relying solely on color to convey information, using icons and text labels in addition to color coding.
Section sources
- chat.css
- markdown.css
- base.css
The CodeMirror integration enhances code block rendering in chat messages with syntax highlighting, line numbers, and copy functionality. This feature is implemented through the CodeMirrorRenderer class and the CodeMirror.svelte component.
The CodeMirrorRenderer monitors the DOM for new code blocks and automatically replaces them with enhanced CodeMirror instances:
export class CodeMirrorRenderer {
private codeBlocks: Map<HTMLElement, CodeBlock> = new Map();
private observer: MutationObserver;
constructor() {
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
this.processElement(node as HTMLElement);
}
});
mutation.removedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
this.cleanupElement(node as HTMLElement);
}
});
});
});
}
public startWatching(container: HTMLElement) {
this.observer.observe(container, {
childList: true,
subtree: true,
});
this.processElement(container);
}
}Each code block is enhanced with a toolbar containing a language label and a copy button:
private replaceWithCodeMirror(preElement: HTMLPreElement, code: string, language: string) {
const container = document.createElement('div');
container.className = 'codemirror-container';
const toolbar = document.createElement('div');
toolbar.className = 'codemirror-toolbar';
const languageLabel = document.createElement('span');
languageLabel.className = 'codemirror-language';
languageLabel.textContent = language || 'text';
const copyButton = document.createElement('button');
copyButton.className = 'codemirror-copy-btn';
copyButton.title = 'Copy code';
// Create icon container
const iconContainer = document.createElement('span');
iconContainer.className = 'codemirror-copy-icon';
copyButton.appendChild(iconContainer);
// Mount the copy icon
let currentIcon = mount(Copy, {
target: iconContainer,
props: { size: 16, weight: 'regular' }
});
copyButton.addEventListener('click', () => {
navigator.clipboard.writeText(code).then(() => {
// Replace with check icon
if (currentIcon) {
try { unmount(currentIcon); } catch {}
}
currentIcon = mount(Check, {
target: iconContainer,
props: { size: 16, weight: 'regular' }
});
setTimeout(() => {
// Replace back with copy icon
if (currentIcon) {
try { unmount(currentIcon); } catch {}
}
currentIcon = mount(Copy, {
target: iconContainer,
props: { size: 16, weight: 'regular' }
});
}, 1000);
}).catch(() => {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = code;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
// Replace with check icon
if (currentIcon) {
try { unmount(currentIcon); } catch {}
}
currentIcon = mount(Check, {
target: iconContainer,
props: { size: 16, weight: 'regular' }
});
setTimeout(() => {
// Replace back with copy icon
if (currentIcon) {
try { unmount(currentIcon); } catch {}
}
currentIcon = mount(Copy, {
target: iconContainer,
props: { size: 16, weight: 'regular' }
});
}, 1000);
});
});
toolbar.appendChild(languageLabel);
toolbar.appendChild(copyButton);
const editorContainer = document.createElement('div');
editorContainer.className = 'codemirror-editor';
container.appendChild(toolbar);
container.appendChild(editorContainer);
// Replace the pre element
preElement.parentNode?.replaceChild(container, preElement);
// Mount CodeMirror component
try {
const component = mount(CodeMirror, {
target: editorContainer,
props: {
code: code,
language: language,
readonly: true,
theme: 'auto',
showLineNumbers: true,
wrap: true
}
});
// Store reference for cleanup
this.codeBlocks.set(container, {
element: container,
code,
language,
component,
iconComponent: currentIcon
});
} catch (error) {
console.error('Failed to mount CodeMirror component:', error);
// Fallback to original pre element
container.parentNode?.replaceChild(preElement, container);
}
}The CodeMirror.svelte component provides the actual editor implementation with support for multiple languages:
const languageExtensions: Record<string, () => any> = {
'javascript': () => javascript(),
'js': () => javascript(),
'typescript': () => javascript({ typescript: true }),
'ts': () => javascript({ typescript: true }),
'python': () => python(),
'py': () => python(),
'html': () => html(),
'css': () => css(),
'json': () => json(),
'xml': () => xml(),
'sql': () => sql(),
'jsx': () => javascript({ jsx: true }),
'tsx': () => javascript({ typescript: true, jsx: true }),
};Language detection is performed automatically based on class names or content patterns:
private extractLanguage(codeElement: Element): string {
const classList = Array.from(codeElement.classList);
for (const className of classList) {
if (className.startsWith('hljs-')) {
continue;
}
if (className.startsWith('language-')) {
return className.replace('language-', '');
}
if (className.startsWith('lang-')) {
return className.replace('lang-', '');
}
}
// Check parent pre element
if (codeElement.parentElement) {
const parentClassList = Array.from(codeElement.parentElement.classList);
for (const className of parentClassList) {
if (className.startsWith('language-')) {
return className.replace('language-', '');
}
}
}
// Try to detect language from content
return this.detectLanguageFromContent(codeElement.textContent || '');
}The integration is initialized in the markdown rendering process:
export function appendMarkdownText(ctx: BubbleCtx, text: string): BubbleCtx {
const normalized = text.replace(/\r/g, "");
ctx.mdText += normalized;
if (ctx.mdContentEl) {
ctx.mdContentEl.innerHTML = renderMarkdownToSafeHtml(ctx.mdText);
// Apply CodeMirror rendering to code blocks
try {
if (!ctx.codeMirrorWatching) {
const renderer = getCodeMirrorRenderer();
renderer.startWatching(ctx.mdContentEl);
ctx.codeMirrorWatching = true;
}
} catch (error) {
console.error('Failed to apply CodeMirror rendering:', error);
}
}
// ... rest of function
}The markdown rendering system has been enhanced with improved security and feature detection:
export function renderMarkdownToSafeHtml(markdownText: string): string {
try {
let input = markdownText ?? '';
// Normalize line breaks
input = input.replace(/\r\n?/g, '\n');
// Handle special markdown blocks
let enhanced = input.replace(/```
(?:markdown|md|gfm)\s*\n([\s\S]*?)
```/gi, (_m, inner) => inner);
enhanced = enhanced.replace(/~~~(?:markdown|md|gfm)\s*\n([\s\S]*?)~~~/gi, (_m, inner) => inner);
// Process incomplete markdown blocks
if (/^```
(?:markdown|md|gfm)\s*$/im.test(enhanced)) {
const lines = enhanced.split(/\n/);
let inMdFence = false;
const out: string[] = [];
for (const line of lines) {
if (/^
```(?:markdown|md|gfm)\s*$/i.test(line)) {
inMdFence = true;
continue;
}
if (inMdFence && /^```
+\s*$/.test(line)) {
inMdFence = false;
continue;
}
out.push(line);
}
enhanced = out.join('\n');
}
// Parse markdown and sanitize HTML
const dirty = (typeof (marked as any).parse === 'function'
? (marked as any).parse(enhanced)
: (marked as any)(enhanced)) as string;
// Sanitize with DOMPurify
const sanitized = typeof window !== 'undefined'
? DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: [
// Standard Markdown tags
'h1','h2','h3','h4','h5','h6','p','br','hr','div','section','article','aside',
'nav','header','footer','main','address',
// Text formatting
'strong','em','b','i','u','s','mark','small','del','ins','sub','sup',
// Lists and tasks
'ul','ol','li','dl','dt','dd',
// Quotes and code
'blockquote','code','pre','kbd','samp','var',
// Links and media
'a','img','figure','figcaption','picture','source',
// Tables
'table','thead','tbody','tfoot','tr','th','td','caption','colgroup','col',
// Interactive elements
'details','summary',
// Semantic elements
'span','abbr','dfn','q','cite','time','data','output',
// Math and formulas
'math','mi','mo','mn','ms','mtext','mrow','msup','msub','mfrac','msqrt','mroot',
// Ruby annotations
'ruby','rt','rp',
// Additional elements
'wbr','bdi','bdo'
],
ALLOWED_ATTR: [
// Links and navigation
'href','title','target','rel','download','hreflang',
// Media attributes
'src','alt','width','height','sizes','srcset','loading','decoding',
// Identifiers and metadata
'id','name','class','lang','dir','translate',
// Styles and data
'style','data-*','content',
// Accessibility
'aria-*','role','tabindex','accesskey',
// Tables
'colspan','rowspan','scope','headers','summary',
// Forms and interactivity
'type','value','placeholder','readonly','disabled','checked',
'min','max','step','pattern','required','autocomplete',
// Date and time
'datetime','cite','open','reversed','start',
// Media content
'controls','autoplay','muted','loop','preload','poster',
// Math
'mathvariant','mathsize','mathcolor','mathbackground',
// Additional
'hidden','contenteditable','spellcheck','draggable'
]
})
: dirty;
return sanitized;
} catch {
return DOMPurify.sanitize(markdownText ?? '');
}
}The CodeMirror integration includes responsive design improvements for mobile devices:
css
/* Mobile responsive adjustments */
@media (max-width: 768px) {
.codemirror-container :global(.cm-content) {
padding: 12px !important;
font-size: 13px !important;
}
.codemirror-toolbar {
padding: 6px 10px;
}
.codemirror-language {
font-size: 10px;
}
.codemirror-copy-btn {
padding: 3px 6px;
}
.codemirror-copy-icon :global(svg) {
width: 14px;
height: 14px;
}
}
Section sources
- codemirror-renderer.ts
- CodeMirror.svelte
- codemirror.css
- markdown_block.ts
- bubble_ctx.ts
- markdown.ts
The application version is now dynamically displayed in the sidebar footer, providing users with immediate access to the current version information. This feature is implemented in the Sidebar.svelte component using Tauri's getVersion API to retrieve the version from the application configuration.
The version display is implemented as a simple text element in the sidebar footer:
html
<div class="sidebar-footer">
<div class="footer-info">
<small>v{appVersion}</small>
</div>
</div>
The version information is retrieved asynchronously when the component mounts:
typescript
<script lang="ts">
import { getVersion } from '@tauri-apps/api/app';
import { onMount } from 'svelte';
let appVersion = '';
onMount(async () => {
try {
appVersion = await getVersion();
} catch (error) {
console.error('Failed to get app version:', error);
appVersion = 'Unknown';
}
});
</script>
The getVersion function is imported from the Tauri API and returns a Promise that resolves to a string containing the application version:
typescript
/**
* Gets the application version.
* @example
*
```typescript
* import { getVersion } from '@tauri-apps/api/app';
* const appVersion = await getVersion();
* ```
*
* @since 1.0.0
*/
declare function getVersion(): Promise<string>;
The styling for the version display is minimal but consistent with the overall sidebar design:
css
.sidebar-footer {
padding: 8px 6px;
border-top: 1px solid var(--border-color, #e8e6e3);
}
.footer-info { text-align: center; color: var(--muted, #6d6a6a); }
.footer-info small { font-size: 10px; }
In dark mode, the version text color is adjusted for proper contrast:
css
@media (prefers-color-scheme: dark) {
.sidebar-footer { border-top-color: #333; }
.footer-info { color: #6d6a6a; }
}
The version display provides a fallback value of "Unknown" if the version cannot be retrieved, ensuring that the UI remains functional even if there are issues with the Tauri API call.
Section sources
- Sidebar.svelte
- app.d.ts
A new messages toolbar has been added above the message history display, containing sidebar toggle buttons on both left and right sides. This toolbar provides quick access to toggle the sidebar visibility from the chat interface.
The toolbar is implemented in the MessageList.svelte component with two buttons that use the SidebarSimple icon from the Phosphor icon library:
svelte
<div class="messages-toolbar">
<button class="toolbar-button left">
<SidebarSimple size="20" />
</button>
<button class="toolbar-button right">
<SidebarSimple size="20" />
</button>
</div>
The buttons are styled to appear as simple icon buttons with hover effects:
css
.messages-toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
margin-top: -10px;
margin-left: -6px;
margin-right: -6px;
}
.toolbar-button {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text);
}
.toolbar-button:hover {
background-color: var(--border-color);
}
The right button is mirrored using CSS transform to provide a visual distinction from the left button:
css
.toolbar-button.right {
margin-left: auto;
transform: scaleX(-1);
}
This implementation allows users to toggle the sidebar from either side of the message list, providing convenient access to sidebar functionality without navigating away from the chat area.
Section sources
- MessageList.svelte
The application header has been made draggable to allow window movement by clicking and dragging anywhere on the header bar. This implementation enhances the user experience by providing intuitive window control.
The dragging functionality is implemented in the +layout.svelte file, where the entire header element has an onmousedown event listener that triggers the window dragging:
svelte
<header class="app-header" onmousedown={startDragging}>
The startDragging function checks if the click target is not on an interactive element before initiating the drag:
typescript
async function startDragging(event: MouseEvent) {
// Only start dragging if we're not clicking on an interactive element
const target = event.target as HTMLElement;
if (!target.closest('button, input, [data-tauri-drag-region="false"]')) {
await appWindow.startDragging();
}
}
This implementation uses Tauri's appWindow.startDragging() method to initiate the window drag operation. The function specifically excludes interactive elements like buttons and inputs from triggering the drag behavior, ensuring that users can interact with header controls without accidentally moving the window.
The header is styled to provide a consistent visual appearance:
css
.app-header {
position: sticky; top: 0; z-index: 100;
background: var(--card); color: var(--text);
display: flex; align-items: center; justify-content: space-between;
padding: 10px 8px; border-bottom: 1px solid var(--border-color);
height: 56px; min-height: 56px; box-sizing: border-box;
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
user-select: none;
-webkit-user-select: none;
cursor: default;
}
This custom window dragging implementation provides a native-like desktop application experience, allowing users to reposition the application window conveniently from the header area.
Section sources
- +layout.svelte
Referenced Files in This Document
- actions.ts
- think_html.ts
- chat.css
- base.css
- render_impl.ts
- bubble_ctx.ts
- markdown_block.ts
- types.ts
- types.ts
- markdown.css
- progress.css
- codemirror-renderer.ts - Updated in recent commit
- CodeMirror.svelte - Added in recent commit
- codemirror.css - Updated in recent commit
- markdown.ts - Updated in recent commit
- Sidebar.svelte - Updated in recent commit
- MessageList.svelte - Updated in recent commit
- +layout.svelte - Updated in recent commit