ui: ESLint config updates (#27700)

* chore: Spacing between sibling elements in html markup

* chore: Formatting and linting rules
This commit is contained in:
Aleksander Grygier
2026-08-25 14:34:34 +02:00
committed by GitHub
parent 3737e41370
commit f1357e4998
267 changed files with 1706 additions and 1344 deletions
+151 -3
View File
@@ -12,6 +12,107 @@ import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
// Require a blank line between sibling element-like nodes in a Svelte template
// (elements, components, and the {#if} / {#each} / {#await} / {#snippet} /
// {@render} blocks) that sit on separate lines at the same nesting level.
// Whitespace between siblings is a whitespace-only SvelteText node; when it
// holds a single newline (no blank line) the fix adds one, keeping the
// indentation of the second sibling. Real text content (e.g. `foo\n\nbar`)
// is left alone.
const ELEMENT_LIKE_TYPES = new Set([
'SvelteAwaitBlock',
'SvelteComponent',
'SvelteEachBlock',
'SvelteElement',
'SvelteIfBlock',
'SvelteKeyBlock',
'SvelteRenderTag',
'SvelteSelf',
'SvelteSnippetBlock'
]);
const paddingLineBetweenElements = {
create(context) {
// Check one list of template children. Each children array holds the
// element-like nodes plus the whitespace/comment text between them.
function checkChildren(children) {
if (!Array.isArray(children)) return;
let lastElement = null;
let lastWhitespace = null;
for (const child of children) {
if (child.type === 'SvelteText' && /^\s*$/.test(child.value)) {
lastWhitespace = child;
continue;
}
if (!ELEMENT_LIKE_TYPES.has(child.type)) continue;
if (
lastElement &&
lastWhitespace &&
child.loc.start.line - lastElement.loc.end.line === 1
) {
const textNode = lastWhitespace;
context.report({
fix(fixer) {
// Add a second newline so the two siblings are separated by a
// blank line, keeping the trailing indentation.
return fixer.replaceText(textNode, textNode.value.replace(/\n/, '\n\n'));
},
message: 'Expected a blank line between sibling elements.',
node: child
});
}
lastElement = child;
lastWhitespace = null;
}
}
return {
SvelteAwaitBlock(node) {
checkChildren(node.children);
checkChildren(node.then?.children);
checkChildren(node.else?.children);
},
SvelteComponent(node) {
checkChildren(node.children);
},
SvelteEachBlock(node) {
checkChildren(node.children);
checkChildren(node.else?.children);
},
SvelteElement(node) {
checkChildren(node.children);
},
SvelteFragment(node) {
checkChildren(node.children);
},
SvelteIfBlock(node) {
checkChildren(node.children);
checkChildren(node.else?.children);
},
SvelteKeyBlock(node) {
checkChildren(node.children);
},
SvelteProgram(node) {
checkChildren(node.children);
},
SvelteSnippetBlock(node) {
checkChildren(node.children);
}
};
},
meta: {
docs: { description: 'Require a blank line between sibling elements in a Svelte template.' },
fixable: 'whitespace',
schema: [],
type: 'layout'
}
};
// Require a blank line between consecutive class accessors (get/set). The core
// `padding-line-between-statements` rule only handles statements, not class
// members, so this is enforced with a small custom rule.
@@ -66,7 +167,12 @@ export default ts.config(
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
plugins: {
local: { rules: { 'blank-line-between-accessors': blankLineBetweenAccessors } },
local: {
rules: {
'blank-line-between-accessors': blankLineBetweenAccessors,
'padding-line-between-elements': paddingLineBetweenElements
}
},
perfectionist,
'simple-import-sort': simpleImportSort
},
@@ -82,6 +188,8 @@ export default ts.config(
'eol-last': 'error',
// Enforce a blank line between consecutive get/set accessors
'local/blank-line-between-accessors': 'error',
// Require a blank line between sibling elements in a Svelte template
'local/padding-line-between-elements': 'error',
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off',
@@ -156,9 +264,49 @@ export default ts.config(
// grouping); Prettier normalizes comma spacing afterwards.
'simple-import-sort/imports': ['error', { groups: [['.*']] }],
'svelte/no-at-html-tags': 'off',
// This app uses hash-based routing (#/) where resolve() from $app/paths does not apply
'svelte/no-navigation-without-resolve': 'off'
'svelte/no-navigation-without-resolve': 'off',
// Sort HTML attributes alphabetically in the markup. The Svelte directives
// (bind:/use:/animate:/style:/in:/out:/transition:/class:) sort first,
// alphabetically among themselves, then all remaining attributes sort
// alphabetically. The rule keeps spread attributes in place and does not cross
// them. `this` stays first on <svelte:element> because Prettier forces it there
// - reordering it alphabetically would fight the formatter.
'svelte/sort-attributes': [
'error',
{
order: [
'this',
{
match: [
'/^bind:/u',
'/^use:/u',
'/^animate:/u',
'/^style:/u',
'/^in:/u',
'/^out:/u',
'/^transition:/u',
'/^class:/u'
],
sort: 'alphabetical'
},
{
match: [
'!/^bind:/u',
'!/^use:/u',
'!/^animate:/u',
'!/^style:/u',
'!/^in:/u',
'!/^out:/u',
'!/^transition:/u',
'!/^class:/u'
],
sort: 'alphabetical'
}
]
}
]
}
},
{
@@ -41,17 +41,17 @@
{#snippet button(props = {})}
<Button
{...props}
{href}
{variant}
{size}
aria-label={ariaLabel || tooltip}
class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
{disabled}
{href}
onclick={(e: MouseEvent) => {
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}
{size}
{variant}
>
{#if icon}
{@const IconComponent = icon}
@@ -10,9 +10,9 @@
</script>
<ActionIcon
icon={Copy}
tooltip={ariaLabel}
iconSize={ICON_CLASS_DEFAULT}
disabled={!canCopy}
icon={Copy}
iconSize={ICON_CLASS_DEFAULT}
onclick={() => canCopy && copyToClipboard(text)}
tooltip={ariaLabel}
/>
@@ -108,13 +108,13 @@
{/if}
<DialogChatAttachmentsPreview
bind:open={viewAllDialogOpen}
{activeModelId}
{attachments}
bind:open={viewAllDialogOpen}
{previewFocusIndex}
{uploadedFiles}
/>
{#if mcpResourcePreviewExtra}
<DialogMcpResourcePreview extra={mcpResourcePreviewExtra} bind:open={mcpResourcePreviewOpen} />
<DialogMcpResourcePreview bind:open={mcpResourcePreviewOpen} extra={mcpResourcePreviewExtra} />
{/if}
@@ -75,58 +75,58 @@
{#if mcpPrompt}
<ChatAttachmentsListItemMcpPrompt
class="max-w-[300px] min-w-[200px] flex-shrink-0 {className} {scrollClasses}"
prompt={mcpPrompt}
{readonly}
isLoading={item.isLoading}
loadError={item.loadError}
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
prompt={mcpPrompt}
{readonly}
/>
{/if}
{:else if isMcpResource(item)}
{@const mcpResource = item.attachment as DatabaseMessageExtraMcpResource}
<ChatAttachmentsListItemMcpResource
class="flex-shrink-0 {className} {scrollClasses}"
attachment={toMcpResourceAttachment(mcpResource, item.id)}
class="flex-shrink-0 {className} {scrollClasses}"
onclick={() => onMcpResourcePreview?.(mcpResource)}
/>
{:else if item.isImage && item.preview}
<ChatAttachmentsListItemThumbnailImage
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
height={imageHeight}
id={item.id}
{imageClass}
name={item.name}
onRemove={onFileRemove}
onclick={() => onPreview?.(item)}
preview={item.preview}
{readonly}
onRemove={onFileRemove}
height={imageHeight}
width={imageWidth}
{imageClass}
onclick={() => onPreview?.(item)}
/>
{:else if isPdfFile(item.attachment, item.uploadedFile)}
<ChatAttachmentsListItemThumbnailFile
attachment={item.attachment}
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
id={item.id}
name={item.name}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onclick={() => onPreview?.(item)}
{readonly}
size={item.size}
textContent={item.textContent}
uploadedFile={item.uploadedFile}
/>
{:else}
<ChatAttachmentsListItemThumbnailFile
attachment={item.attachment}
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
id={item.id}
name={item.name}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onclick={() => onPreview?.(item)}
{readonly}
size={item.size}
textContent={item.textContent}
uploadedFile={item.uploadedFile}
/>
{/if}
@@ -35,7 +35,7 @@
<div
class="absolute top-10 right-2 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
>
<ActionIcon icon={X} tooltip="Remove" stopPropagationOnClick onclick={() => onRemove?.()} />
<ActionIcon icon={X} onclick={() => onRemove?.()} stopPropagationOnClick tooltip="Remove" />
</div>
{/if}
</div>
@@ -101,7 +101,7 @@
<div
class="absolute top-2 right-2 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
>
<ActionIcon icon={X} tooltip="Remove" stopPropagationOnClick onclick={() => onRemove?.(id)} />
<ActionIcon icon={X} onclick={() => onRemove?.(id)} stopPropagationOnClick tooltip="Remove" />
</div>
{/snippet}
@@ -30,7 +30,7 @@
</script>
{#snippet image()}
<img src={preview} alt={name} class="{height} {width} cursor-pointer object-cover {imageClass}" />
<img alt={name} class="{height} {width} cursor-pointer object-cover {imageClass}" src={preview} />
{/snippet}
<div
@@ -185,30 +185,30 @@
<div class="{className} flex flex-col text-white">
<div class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden">
<ChatAttachmentsPreviewNavButtons onPrev={prev} onNext={next} show={allItems.length > 1} />
<ChatAttachmentsPreviewNavButtons onNext={next} onPrev={prev} show={allItems.length > 1} />
<div class="flex h-full w-full flex-col items-center justify-start overflow-auto py-4">
{#if currentItem}
<ChatAttachmentsPreviewFileInfo {displayName} {fileSize} />
<ChatAttachmentsPreviewCurrentItem
{activeModelId}
{audioSrc}
{currentItem}
{isImage}
{isAudio}
{isVideo}
{isPdf}
{isText}
{displayPreview}
{displayTextContent}
{audioSrc}
{videoSrc}
{language}
{hasVisionModality}
{activeModelId}
{isAudio}
{isImage}
{isPdf}
{isText}
{isVideo}
{language}
{videoSrc}
/>
{/if}
<ChatAttachmentsPreviewThumbnailStrip items={allItems} {currentIndex} {onNavigate} />
<ChatAttachmentsPreviewThumbnailStrip {currentIndex} items={allItems} {onNavigate} />
</div>
</div>
</div>
@@ -53,18 +53,18 @@
{#key currentItem.id}
{#if isPdf}
<ChatAttachmentsPreviewCurrentItemPdf
{activeModelId}
{currentItem}
displayName={currentItem.name}
{displayTextContent}
{hasVisionModality}
{activeModelId}
/>
{:else if isImage}
<ChatAttachmentsPreviewCurrentItemImage {currentItem} {displayPreview} />
{:else if isText && displayTextContent}
<ChatAttachmentsPreviewCurrentItemText {displayTextContent} {language} />
{:else if isAudio}
<ChatAttachmentsPreviewCurrentItemAudio {currentItem} {audioSrc} />
<ChatAttachmentsPreviewCurrentItemAudio {audioSrc} {currentItem} />
{:else if isVideo}
<ChatAttachmentsPreviewCurrentItemVideo {currentItem} {videoSrc} />
{:else if isUnavailable}
@@ -14,7 +14,7 @@
<Music class="mx-auto mb-4 h-16 w-16 text-white/50" />
{#if audioSrc}
<audio controls class="mb-4 w-full" src={audioSrc}>
<audio class="mb-4 w-full" controls src={audioSrc}>
Your browser does not support the audio element.
</audio>
{:else}
@@ -10,9 +10,9 @@
{#if displayPreview}
<div class="flex flex-1 items-center justify-center">
<img
src={displayPreview}
alt={currentItem?.name || 'preview'}
class="max-h-[80vh] max-w-[80vw] rounded-lg object-contain shadow-lg"
src={displayPreview}
/>
</div>
{/if}
@@ -87,20 +87,20 @@
<div class="mb-4 flex items-center justify-end gap-2">
<Button
variant={pdfViewMode === PdfViewMode.TEXT ? 'default' : 'outline'}
size="sm"
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
disabled={pdfImagesLoading}
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
size="sm"
variant={pdfViewMode === PdfViewMode.TEXT ? 'default' : 'outline'}
>
<FileText class="mr-1 {ICON_CLASS_DEFAULT}" />
Text
</Button>
<Button
variant={pdfViewMode === PdfViewMode.PAGES ? 'default' : 'outline'}
size="sm"
onclick={() => (pdfViewMode = PdfViewMode.PAGES)}
disabled={pdfImagesLoading}
onclick={() => (pdfViewMode = PdfViewMode.PAGES)}
size="sm"
variant={pdfViewMode === PdfViewMode.PAGES ? 'default' : 'outline'}
>
{#if pdfImagesLoading}
<div
@@ -116,7 +116,9 @@
{#if !hasVisionModality && activeModelId && currentItem}
<Alert.Root class="mb-4 max-w-4xl">
<Info class={ICON_CLASS_DEFAULT} />
<Alert.Title>Preview only</Alert.Title>
<Alert.Description>
<span class="inline-flex">
The selected model does not support vision. Only the extracted
@@ -140,6 +142,7 @@
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-white border-t-transparent"
></div>
<p class="text-white/70">Converting PDF to images...</p>
</div>
</div>
@@ -147,20 +150,25 @@
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<FileText class="mx-auto mb-4 h-16 w-16 text-white/50" />
<p class="mb-4 text-white/70">Failed to load PDF images</p>
<p class="text-sm text-white/50">{pdfImagesError}</p>
</div>
</div>
{:else if pdfImages.length > 0}
{#each pdfImages as image, index (image)}
<p class="mb-2 text-sm text-white/50">Page {index + 1}</p>
<img src={image} alt="PDF Page {index + 1}" class="mx-auto max-w-[85vw] rounded-lg shadow-lg" />
<img alt="PDF Page {index + 1}" class="mx-auto max-w-[85vw] rounded-lg shadow-lg" src={image} />
<div class="h-4"></div>
{/each}
{:else}
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<FileText class="mx-auto mb-4 h-16 w-16 text-white/50" />
<p class="text-white/70">No PDF pages available</p>
</div>
</div>
@@ -14,7 +14,7 @@
<Video class="mx-auto mb-4 h-16 w-16 text-white/50" />
{#if videoSrc}
<video controls class="mb-4 w-full" src={videoSrc}>
<video class="mb-4 w-full" controls src={videoSrc}>
<track kind="captions" src="" />
Your browser does not support the video element.
</video>
@@ -13,21 +13,21 @@
{#if show}
<Button
variant="secondary"
size="icon"
aria-label="Previous"
class="absolute top-1/2 left-4 z-10 h-8 w-8 -translate-y-1/2 rounded-full bg-background/5 p-0 text-white!"
onclick={onPrev}
aria-label="Previous"
size="icon"
variant="secondary"
>
<ChevronLeft class="size-4" />
</Button>
<Button
variant="secondary"
size="icon"
aria-label="Next"
class="absolute top-1/2 right-4 z-10 h-8 w-8 -translate-y-1/2 rounded-full bg-background/5 p-0 text-white!"
onclick={onNext}
aria-label="Next"
size="icon"
variant="secondary"
>
<ChevronRight class="size-4" />
</Button>
@@ -38,16 +38,16 @@
{#each items as item, index (item.id)}
<button
{...{ [UI_DATA_ATTRS.THUMBNAIL_INDEX]: index }}
aria-label={`Go to ${item.name}`}
class={[
'relative flex-shrink-0 cursor-pointer overflow-hidden rounded border-2 bg-black/80 backdrop-blur-sm transition-all hover:opacity-90',
index === currentIndex ? 'border-white' : 'border-transparent opacity-60',
'[&:not(:first-child)]:last:mr-4 [&:not(:last-child)]:first:ml-4'
]}
onclick={() => onNavigate(index)}
aria-label={`Go to ${item.name}`}
>
{#if item.isImage && item.preview}
<img src={item.preview} alt={item.name} class="h-12 w-12 object-cover" />
<img alt={item.name} class="h-12 w-12 object-cover" src={item.preview} />
{:else}
<div
class="bg-foreground-muted/50 flex h-12 w-12 flex-col items-center justify-center gap-0.5 py-1"
@@ -537,30 +537,30 @@
>
<ChatFormPickers
bind:this={pickersRef}
isCommandPickerOpen={pickers.isCommandPickerOpen}
commandQuery={pickers.commandQuery}
commands={pickers.availableCommands}
isCommandPickerOpen={pickers.isCommandPickerOpen}
isMentionPickerOpen={pickers.isMentionPickerOpen}
isPromptPickerOpen={pickers.isPromptPickerOpen}
{mentionAnchor}
mentionQuery={pickers.mentionQuery}
onCommandPickerClose={pickers.handleCommandPickerClose}
onCommandSelect={pickers.handleCommandSelect}
isPromptPickerOpen={pickers.isPromptPickerOpen}
promptSearchQuery={pickers.promptSearchQuery}
isMentionPickerOpen={pickers.isMentionPickerOpen}
mentionQuery={pickers.mentionQuery}
{mentionAnchor}
scopePath={pickers.mentionScopePath}
onPromptPickerClose={pickers.handlePromptPickerClose}
onMentionPickerClose={pickers.handleMentionPickerClose}
onMentionOpened={() => inputRef?.focus()}
onMentionPickerClose={pickers.handleMentionPickerClose}
onMentionSelect={handleMentionSelect}
onPromptLoadStart={handlePromptLoadStart}
onPromptLoadComplete={handlePromptLoadComplete}
onPromptLoadError={handlePromptLoadError}
onPromptLoadStart={handlePromptLoadStart}
onPromptPickerClose={pickers.handlePromptPickerClose}
promptSearchQuery={pickers.promptSearchQuery}
scopePath={pickers.mentionScopePath}
/>
<div
bind:this={mentionAnchor}
class="pointer-events-none absolute top-0 right-0 left-0 h-px"
aria-hidden="true"
class="pointer-events-none absolute top-0 right-0 left-0 h-px"
></div>
<div
@@ -570,29 +570,29 @@
data-slot="input-area"
>
<ChatAttachmentsList
{attachments}
bind:uploadedFiles
onFileRemove={handleFileRemove}
limitToSingleRow
class="py-5"
style="scroll-padding: 1rem;"
activeModelId={activeModelId ?? undefined}
{attachments}
class="py-5"
limitToSingleRow
onFileRemove={handleFileRemove}
style="scroll-padding: 1rem;"
/>
<div
class="flex-column relative min-h-12 items-center rounded-4xl md:rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:py-3!"
>
<ChatFormInput
class="px-5 py-1.5 md:pt-0"
bind:this={inputRef}
bind:value
onKeydown={handleKeydown}
class="px-5 py-1.5 md:pt-0"
{disabled}
onInput={() => {
pickers.handleInput();
onValueChange?.(value);
}}
onKeydown={handleKeydown}
onPaste={handlePaste}
{disabled}
{placeholder}
{useRichInput}
/>
@@ -608,22 +608,22 @@
{/if}
<ChatFormActions
class="px-3"
bind:this={chatFormActionsRef}
canSend={canSubmit}
class="px-3"
{disabled}
{isLoading}
isReasoning={chatStore.isReasoning}
{isRecording}
{showAddButton}
{showModelSelector}
{uploadedFiles}
onFileUpload={handleFileUpload}
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
onMicClick={handleMicClick}
{onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ files: uploadedFiles, message: value })}
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
{showAddButton}
{showModelSelector}
{uploadedFiles}
/>
</div>
</div>
@@ -632,21 +632,20 @@
{#if toolsStore.hasEnabledCwdTools}
<ChatFormCurrentWorkingDirectory
directory={cwd}
isOpen={pickers.isWorkingDirectoryPickerOpen}
bind:query={pickers.workingDirectoryQuery}
customAnchor={mentionAnchor}
directory={cwd}
{disabled}
isOpen={pickers.isWorkingDirectoryPickerOpen}
onChange={handleWorkingDirectoryChange}
onClose={pickers.handleWorkingDirectoryClose}
onOpen={pickers.handleWorkingDirectoryOpen}
{disabled}
/>
{/if}
</form>
<DialogMcpResourcesBrowser
bind:open={isResourceDialogOpen}
preSelectedUri={preSelectedResourceUri}
onAttach={(resource: MCPResourceInfo) => {
mcpStore.attachResource(resource.uri);
}}
@@ -655,4 +654,5 @@
preSelectedResourceUri = undefined;
}
}}
preSelectedUri={preSelectedResourceUri}
/>
@@ -18,8 +18,8 @@
class="file-upload-button md:h-8 md:w-8 h-9 w-9 rounded-full p-0"
{disabled}
{onclick}
variant="secondary"
type="button"
variant="secondary"
>
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
@@ -70,10 +70,10 @@
<DropdownMenu.SubContent class="w-72 pt-0">
{#if hasMcpServers}
<DropdownMenuSearchable
placeholder="Search servers..."
bind:searchValue={mcpSearchQuery}
emptyMessage="No servers found"
isEmpty={filteredMcpServers.length === 0}
placeholder="Search servers..."
>
<div class="max-h-64 overflow-y-auto">
{#each filteredMcpServers as server (server.id)}
@@ -84,10 +84,10 @@
{@const faviconUrl = mcpStore.getServerFavicon(server.id)}
<button
type="button"
class="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => !hasError && toggleServerForChat(server.id)}
disabled={hasError}
onclick={() => !hasError && toggleServerForChat(server.id)}
type="button"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<div class="min-w-0 flex-1">
@@ -96,8 +96,8 @@
{faviconUrl}
iconClass={ICON_CLASS_DEFAULT}
iconRounded="rounded-sm"
showVersion={false}
nameClass="text-sm"
showVersion={false}
/>
</div>
@@ -113,8 +113,8 @@
<Switch
checked={isEnabledForChat}
disabled={hasError}
onclick={(e) => e.stopPropagation()}
onCheckedChange={() => toggleServerForChat(server.id)}
onclick={(e) => e.stopPropagation()}
/>
</button>
{/each}
@@ -64,6 +64,7 @@
<Tooltip.Trigger>
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p>Maximum reasoning effort with extended context usage</p>
</Tooltip.Content>
@@ -78,7 +78,7 @@
<Sheet.Root bind:open={sheetOpen}>
{@render trigger({ disabled: chatFormActions.disabled, onclick: () => (sheetOpen = true) })}
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0 overflow-y-auto">
<Sheet.Content class="max-h-[85vh] gap-0 overflow-y-auto" side="bottom">
<Sheet.Header>
<Sheet.Title>Add to chat</Sheet.Title>
@@ -90,8 +90,8 @@
<div class="flex flex-col gap-1 px-1.5 pb-2">
{#if reasoning.modelSupportsThinking}
<Collapsible.Root
open={reasoningExpanded}
onOpenChange={(open) => (reasoningExpanded = open)}
open={reasoningExpanded}
>
<Collapsible.Trigger class={sheetItemClass}>
{#if reasoningExpanded}
@@ -120,10 +120,10 @@
{#each reasoning.levels as level (level.value)}
{@const tokenLabel = reasoning.tokenLabel(level)}
<button
type="button"
class={sheetItemRowClass}
class:bg-accent={reasoning.isSelected(level)}
class={sheetItemRowClass}
onclick={() => reasoning.select(level)}
type="button"
>
<div class="flex min-w-0 items-center gap-3">
{#if reasoning.isSelected(level)}
@@ -147,7 +147,7 @@
</Collapsible.Root>
{/if}
<Collapsible.Root open={filesExpanded} onOpenChange={(open) => (filesExpanded = open)}>
<Collapsible.Root onOpenChange={(open) => (filesExpanded = open)} open={filesExpanded}>
<Collapsible.Trigger class={sheetItemClass}>
{#if filesExpanded}
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
@@ -166,9 +166,9 @@
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
{#if enabled}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[item.action]()}
type="button"
>
<item.icon class="{ICON_CLASS_DEFAULT} shrink-0" />
@@ -177,7 +177,7 @@
{:else if item.disabledTooltip}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button type="button" class={sheetItemClass} disabled>
<button class={sheetItemClass} disabled type="button">
<item.icon class="{ICON_CLASS_DEFAULT} shrink-0" />
<span>{item.label}</span>
@@ -194,7 +194,7 @@
</Collapsible.Content>
</Collapsible.Root>
<Collapsible.Root open={mcpExpanded} onOpenChange={(open) => (mcpExpanded = open)}>
<Collapsible.Root onOpenChange={(open) => (mcpExpanded = open)} open={mcpExpanded}>
<Collapsible.Trigger class={sheetItemClass}>
{#if mcpExpanded}
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
@@ -223,21 +223,21 @@
)}
<button
type="button"
class={sheetItemRowClass}
disabled={hasError}
onclick={() =>
!hasError && conversationsStore.preferences.toggleMcpServerForChat(server.id)}
disabled={hasError}
type="button"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={faviconUrl}
/>
{/if}
@@ -270,7 +270,7 @@
</Collapsible.Root>
{#if toolsPanel.totalToolCount > 0}
<Collapsible.Root open={toolsExpanded} onOpenChange={(open) => (toolsExpanded = open)}>
<Collapsible.Root onOpenChange={(open) => (toolsExpanded = open)} open={toolsExpanded}>
<Collapsible.Trigger class={sheetItemClass}>
{#if toolsExpanded}
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
@@ -295,18 +295,18 @@
{@const favicon = toolsPanel.getFavicon(group)}
<button
type="button"
class={sheetItemRowClass}
onclick={() => toolsPanel.toggleGroupByKey(group.key)}
type="button"
>
{#if favicon}
<img
src={favicon}
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/if}
@@ -319,8 +319,8 @@
<Checkbox
{checked}
class="{ICON_CLASS_DEFAULT} shrink-0"
onclick={(e) => e.stopPropagation()}
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
onclick={(e) => e.stopPropagation()}
/>
</button>
{/each}
@@ -330,9 +330,9 @@
{/if}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
type="button"
>
<MessageSquare class="{ICON_CLASS_DEFAULT} shrink-0" />
@@ -341,9 +341,9 @@
{#if chatFormActions.hasMcpPromptsSupport}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_PROMPT_CLICK]()}
type="button"
>
<Zap class="{ICON_CLASS_DEFAULT} shrink-0" />
@@ -353,9 +353,9 @@
{#if chatFormActions.hasMcpResourcesSupport}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_RESOURCES_CLICK]()}
type="button"
>
<FolderOpen class="{ICON_CLASS_DEFAULT} shrink-0" />
@@ -68,8 +68,8 @@
{@const favicon = toolsPanel.getFavicon(group)}
<Collapsible.Root
open={isExpanded}
onOpenChange={() => toolsPanel.toggleGroupExpanded(group.key)}
open={isExpanded}
>
<div class="flex items-center gap-1">
<Collapsible.Trigger
@@ -84,12 +84,12 @@
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
{#if favicon}
<img
src={favicon}
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/if}
@@ -107,8 +107,8 @@
<Checkbox
{...props}
{checked}
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
class="mr-2 {ICON_CLASS_DEFAULT} shrink-0"
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
/>
{/snippet}
</Tooltip.Trigger>
@@ -127,14 +127,14 @@
{#each group.tools as entry (entry.key)}
{@const enabled = toolsStore.isToolEnabled(entry.key)}
<button
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/50"
onclick={() => toolsStore.toggleTool(entry.key)}
type="button"
>
<span
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
data-slot="checkbox"
data-state={enabled ? 'checked' : 'unchecked'}
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
{#if enabled}
<Check class="size-3.5" />
@@ -153,17 +153,17 @@
{#if deviceStore.isMobile}
<ModelsSelectorSheet
disabled={disabled || isOffline}
bind:this={selectorModelRef}
currentModel={selectorModel}
disabled={disabled || isOffline}
{forceForegroundText}
{useGlobalSelection}
/>
{:else}
<ModelsSelectorDropdown
disabled={disabled || isOffline}
bind:this={selectorModelRef}
currentModel={selectorModel}
disabled={disabled || isOffline}
{forceForegroundText}
{useGlobalSelection}
/>
@@ -17,16 +17,17 @@
{#snippet submitButton(props = {})}
<Button
type="submit"
disabled={isDisabled}
class={[
'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'
]}
disabled={isDisabled}
type="submit"
{...props}
>
<span class="sr-only">Send</span>
<ArrowUp class="h-12 w-12" />
</Button>
{/snippet}
@@ -188,14 +188,14 @@
{#if showModelSelector}
<ChatFormActionModels
{disabled}
bind:this={selectorModelRef}
bind:hasAudioModality
bind:hasModelSelected
bind:hasVideoModality
bind:hasVisionModality
bind:hasModelSelected
bind:isSelectedModelInCache
bind:submitTooltip
bind:this={selectorModelRef}
{disabled}
forceForegroundText
useGlobalSelection
/>
@@ -204,12 +204,12 @@
{#if isReasoning}
<Button
type="button"
variant="secondary"
class="group h-8 w-8 rounded-full p-0"
onclick={() =>
ChatService.stopReasoning(activeMessage?.completionId ?? '', activeMessage?.model)}
class="group h-8 w-8 rounded-full p-0"
title="Skip reasoning"
type="button"
variant="secondary"
>
<span class="sr-only">Skip reasoning</span>
@@ -221,10 +221,10 @@
{#if isLoading && !canSubmit}
<Button
class="group h-8 w-8 rounded-full p-0 hover:bg-destructive/10!"
onclick={onStop}
type="button"
variant="secondary"
onclick={onStop}
class="group h-8 w-8 rounded-full p-0 hover:bg-destructive/10!"
>
<span class="sr-only">Stop</span>
@@ -238,8 +238,8 @@
<ChatFormActionSubmit
canSend={canSend && (showModelSelector ? hasModelSelected && isSelectedModelInCache : true)}
{disabled}
tooltipLabel={submitTooltip}
showErrorState={showModelSelector && hasModelSelected && !isSelectedModelInCache}
tooltipLabel={submitTooltip}
/>
{/if}
</div>
@@ -42,16 +42,16 @@
</script>
<div
role="button"
tabindex="0"
aria-label="Context usage"
data-context-gauge-trigger
class="flex h-5 w-5 cursor-default items-center justify-center"
data-context-gauge-trigger
onclick={gaugeTriggerClick}
onkeydown={gaugeTriggerKeydown}
onpointerdown={gaugeTriggerPointerDown}
onpointerenter={gaugeTriggerEnter}
onpointerleave={gaugeTriggerLeave}
role="button"
tabindex="0"
>
<ContextGaugeDial percent={gauge.contextPercent} level={gauge.colorLevel} />
<ContextGaugeDial level={gauge.colorLevel} percent={gauge.contextPercent} />
</div>
@@ -11,6 +11,7 @@
<div class="grid gap-1.5">
<div class="flex items-baseline justify-between">
<span class="text-muted-foreground">{label}</span>
<span class="font-mono text-muted-foreground">{value}</span>
</div>
@@ -57,12 +57,13 @@
{#if cumulativeRead > 0}
<ContextGaugeDetailRow
label="Prompt tokens evaluated"
value={`${cumulativeRead.toLocaleString()} tok`}
subtitle={cumulativeCacheTotal > 0
? `${cumulativeCacheTotal.toLocaleString()} reused from KV cache`
: undefined}
value={`${cumulativeRead.toLocaleString()} tok`}
/>
{/if}
{#if cumulativeOutput > 0}
<ContextGaugeDetailRow
label="Tokens generated"
@@ -83,10 +84,10 @@
{#if currentRead > 0}
<ContextGaugeDetailRow
label="Prompt"
value={`${currentRead.toLocaleString()} tok`}
subtitle={currentCache > 0
? `${currentFresh.toLocaleString()} fresh + ${currentCache.toLocaleString()} cached`
: undefined}
value={`${currentRead.toLocaleString()} tok`}
/>
{/if}
@@ -100,6 +101,7 @@
<div class="pt-1 mt-0.5 border-t border-border/30">
<div class="flex justify-between">
<span class="text-muted-foreground">KV cache total</span>
<span class="font-mono font-medium">{kvTotal.toLocaleString()} tok</span>
</div>
</div>
@@ -18,7 +18,7 @@
const strokeWidth = $derived(size === 'md' ? 4 : 3);
</script>
<svg viewBox="0 0 32 32" fill="none" class={dimensions}>
<svg class={dimensions} fill="none" viewBox="0 0 32 32">
<circle
cx="16"
cy="16"
@@ -29,15 +29,15 @@
/>
<circle
class="transition-colors duration-300 {strokeLevelClass}"
cx="16"
cy="16"
r={RADIUS}
class="transition-colors duration-300 {strokeLevelClass}"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-dasharray={CIRCUMFERENCE}
stroke-dashoffset={percent !== null ? CIRCUMFERENCE * (1 - percent / 100) : CIRCUMFERENCE}
stroke-linecap="round"
stroke-width={strokeWidth}
transform="rotate(-90 16 16)"
/>
</svg>
@@ -14,11 +14,13 @@
{#if modelId !== null && !isLoading}
<div class="flex flex-col gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
<span>Available context size is only visible once the model is loaded.</span>
<Button size="sm" variant="secondary" class="self-start" onclick={onLoad}>Load model</Button>
<Button class="self-start" onclick={onLoad} size="sm" variant="secondary">Load model</Button>
</div>
{:else if isLoading}
<div class="flex items-center gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
<span>Loading model...</span>
</div>
{/if}
@@ -54,17 +54,19 @@
{#if gaugePopup.open}
<div
role="status"
bind:this={cardEl}
class="absolute z-50 w-64 -translate-x-1/2 rounded-lg border border-border/50 bg-popover p-3 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/10"
style="left: {gaugePopup.centerX}px; bottom: {gaugePopup.bottom}px"
onpointerenter={gaugeCardEnter}
onpointerleave={gaugeCardLeave}
role="status"
style="left: {gaugePopup.centerX}px; bottom: {gaugePopup.bottom}px"
>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="font-medium">Context</span>
<span class="text-muted-foreground">·</span>
<span class="font-mono text-muted-foreground">
{formatParameters(gauge.contextUsed)}
/ {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
@@ -73,8 +75,8 @@
{#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
<ContextGaugeLoadModel
modelId={gauge.activeModelId}
isLoading={gauge.isActiveModelLoading}
modelId={gauge.activeModelId}
onLoad={gauge.loadModel}
/>
{:else if showProgressBar}
@@ -91,6 +93,7 @@
<span>
<span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
</span>
<span>
{formatParameters(gauge.contextAvailable ?? 0)} remaining
</span>
@@ -101,15 +104,15 @@
{#if gauge.hasAnyUsage}
<ContextGaugeDetails
currentRead={gauge.currentRead}
currentFresh={gauge.currentFresh}
currentCache={gauge.currentCache}
currentOutput={gauge.currentOutput}
kvTotal={gauge.kvTotal}
cumulativeRead={gauge.cumulativeRead}
cumulativeOutput={gauge.cumulativeOutput}
cumulativeCacheTotal={gauge.cumulativeCacheTotal}
averageTokensPerSecond={gauge.averageTokensPerSecond}
cumulativeCacheTotal={gauge.cumulativeCacheTotal}
cumulativeOutput={gauge.cumulativeOutput}
cumulativeRead={gauge.cumulativeRead}
currentCache={gauge.currentCache}
currentFresh={gauge.currentFresh}
currentOutput={gauge.currentOutput}
currentRead={gauge.currentRead}
kvTotal={gauge.kvTotal}
transientDetails={gauge.transientDetails}
/>
{/if}
@@ -323,80 +323,81 @@
</script>
<button
type="button"
class={[
'justify-self-start flex min-w-0 w-auto items-center gap-1 mt-1.5 py-1 px-2 backdrop-blur-2xl rounded-md',
className
]}
onclick={onOpen}
{disabled}
onclick={onOpen}
type="button"
>
<ChatFormCurrentWorkingDirectoryChip
{directory}
{homeBase}
{disabled}
{showTooltip}
{homeBase}
onClear={handleDismiss}
{showTooltip}
/>
</button>
<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
<Popover.Root onOpenChange={handleOpenChange} open={isOpen}>
<Popover.Trigger
aria-hidden="true"
class="pointer-events-none absolute inset-0 opacity-0"
tabindex={-1}
aria-hidden="true"
>
<span class="sr-only">Open working directory picker</span>
</Popover.Trigger>
<Popover.Content
side="top"
align="start"
sideOffset={12}
{customAnchor}
preventScroll={false}
onkeydown={handleKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl"
{customAnchor}
onCloseAutoFocus={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => event.preventDefault()}
onkeydown={handleKeydown}
preventScroll={false}
side="top"
sideOffset={12}
>
<div class="p-2 min-h-22 flex flex-col justify-between">
<SearchInput
bind:ref={searchInputRef}
bind:value={query}
placeholder="Choose working directory"
onClose={closePicker}
class="w-full"
onClose={closePicker}
placeholder="Choose working directory"
/>
{#if !fileSearchEnabled}
<div class="px-2 py-1.5 text-sm text-muted-foreground">{searchUnavailableMessage}</div>
{:else if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
<ChatFormCurrentWorkingDirectoryResultsList
results={queryResults}
bind:container={listContainer}
error={searchError}
hoveredIndex={nav.hoveredIndex}
isSearching={search.isSearching}
error={searchError}
rawQuery={query}
bind:container={listContainer}
onCommit={commit}
onHover={(index) => nav.setHover(index)}
rawQuery={query}
results={queryResults}
/>
{/if}
{#if pickerSupported && fileSearchEnabled}
<button
type="button"
class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onclick={browseNative}
type="button"
>
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
<span>Browse</span>
</button>
{/if}
{#if homeBase && fileSearchEnabled}
<div class="-mx-2 my-2 h-px bg-border/20" aria-hidden="true"></div>
<div aria-hidden="true" class="-mx-2 my-2 h-px bg-border/20"></div>
<span class="px-2 py-1.5 font-mono text-[10px]">
Searching in:
@@ -29,8 +29,8 @@
</script>
<span
class="text-muted-foreground inline-flex items-center gap-1 text-xs group"
class:text-foreground={directory}
class="text-muted-foreground inline-flex items-center gap-1 text-xs group"
>
<div class="flex min-w-0 items-center gap-1 cursor-pointer">
<Folder class="w-3.5 h-3.5" />
@@ -42,6 +42,7 @@
<span {...props} class="max-w-64 truncate">{displayLabel}</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{displayLabelTitle}</p>
</Tooltip.Content>
@@ -56,14 +57,14 @@
class="w-0 overflow-hidden opacity-0 transition-[width,opacity] duration-200 ease-out group-hover:w-auto group-hover:opacity-100"
>
<ActionIcon
icon={X}
tooltip="Reset working directory"
ariaLabel="Reset working directory"
{disabled}
onclick={onClear}
iconSize="h-3 w-3"
stopPropagationOnClick
class="!h-4 !w-4 shrink-0 text-muted-foreground hover:text-foreground"
{disabled}
icon={X}
iconSize="h-3 w-3"
onclick={onClear}
stopPropagationOnClick
tooltip="Reset working directory"
/>
</div>
{/if}
@@ -34,8 +34,8 @@
<div
bind:this={container}
class="max-h-48 overflow-y-auto py-2"
transition:fly={{ duration: FLY_DURATION_MS, y: FLY_Y_PX }}
class="max-h-48 overflow-y-auto py-2"
>
{#if isSearching && results.length === 0}
<div class="px-2 py-1.5 text-sm text-muted-foreground">Searching...</div>
@@ -48,14 +48,15 @@
<button
type="button"
{...{ [UI_DATA_ATTRS.RESULT_INDEX]: index }}
data-highlighted={index === hoveredIndex ? '' : undefined}
class={cn(
'relative flex w-full cursor-pointer 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-highlighted={index === hoveredIndex ? '' : undefined}
onclick={() => onCommit?.(path)}
onmouseenter={() => onHover?.(index)}
>
<Folder class="size-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-left">
{#each highlightMatch(path, rawQuery.trim()) as seg, segIndex (segIndex)}
{#if seg.match}
@@ -56,23 +56,23 @@
{#if useRichInput}
<ChatFormInputRich
bind:this={richRef}
bind:value
class={className}
{disabled}
{onInput}
{onKeydown}
{onPaste}
{placeholder}
bind:value
/>
{:else}
<ChatFormInputBasic
bind:this={basicRef}
bind:value
class={className}
{disabled}
{onInput}
{onKeydown}
{onPaste}
{placeholder}
bind:value
/>
{/if}
@@ -69,14 +69,14 @@
'text-md min-h-12 w-full resize-none border-0 bg-transparent p-0 leading-6 outline-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0',
disabled && 'cursor-not-allowed'
]}
style="max-height: var(--max-message-height);"
{disabled}
onkeydown={onKeydown}
oninput={(event) => {
autoResizeTextarea(event.currentTarget);
onInput?.();
}}
onkeydown={onKeydown}
onpaste={onPaste}
{placeholder}
style="max-height: var(--max-message-height);"
></textarea>
</div>
@@ -24,8 +24,8 @@
<input
bind:this={fileInputElement}
type="file"
class="hidden {className}"
{multiple}
onchange={handleFileSelect}
class="hidden {className}"
type="file"
/>
@@ -808,25 +808,25 @@
<div class="flex-1 {className} mb-0.5">
<div
bind:this={rootElement}
contenteditable={!disabled}
role="textbox"
aria-multiline="true"
aria-disabled={disabled}
aria-multiline="true"
aria-placeholder={placeholder}
data-placeholder={placeholder}
tabindex={disabled ? -1 : 0}
class={[
'chat-form-input-rich text-md min-h-12 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 leading-6 outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
disabled && 'cursor-not-allowed'
]}
style="max-height: var(--max-message-height);"
oncompositionstart={handleCompositionStart}
contenteditable={!disabled}
data-placeholder={placeholder}
oncompositionend={handleCompositionEnd}
oncompositionstart={handleCompositionStart}
oncopy={handleCopy}
oncut={handleCut}
oninput={handleInput}
onkeydown={handleKeydown}
onpaste={handlePaste}
oncopy={handleCopy}
oncut={handleCut}
role="textbox"
style="max-height: var(--max-message-height);"
tabindex={disabled ? -1 : 0}
></div>
</div>
@@ -27,8 +27,8 @@
<ScrollCarousel gapSize="2" variant={ScrollCarouselVariant.CENTER}>
{#each attachments as attachment, i (attachment.id)}
<ChatAttachmentsListItemMcpResource
class={i === 0 ? 'ml-3' : ''}
{attachment}
class={i === 0 ? 'ml-3' : ''}
onRemove={handleRemove}
onclick={() => handleResourceClick(attachment.resource.uri)}
/>
@@ -21,12 +21,12 @@
<div class="mb-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={faviconUrl}
/>
{/if}
@@ -1,4 +1,4 @@
<script lang="ts" generics="T">
<script generics="T" lang="ts">
import { SearchInput } from '$lib/components/app';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import { CHAT_FORM_POPOVER_MAX_HEIGHT, UI_DATA_ATTRS } from '$lib/constants';
@@ -67,11 +67,11 @@
{#if showSearchInput}
<div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
<SearchInput
{autofocus}
placeholder={searchPlaceholder}
bind:value={searchQuery}
bind:ref={inputRef}
bind:value={searchQuery}
{autofocus}
onClose={onSearchClose}
placeholder={searchPlaceholder}
/>
</div>
{/if}
@@ -85,8 +85,10 @@
{#each { length: skeletonCount } as _, rowIndex (rowIndex)}
<div class="flex items-start gap-3 rounded-lg px-3 py-2">
<div class="mt-0.5 size-4 shrink-0 animate-pulse rounded-md bg-muted/60"></div>
<div class="flex min-w-0 flex-1 flex-col">
<div class="h-5 w-2/5 animate-pulse rounded-sm bg-muted/60"></div>
<div class="h-4 w-1/3 animate-pulse rounded-sm bg-muted/40"></div>
</div>
</div>
@@ -24,11 +24,11 @@
</script>
<button
type="button"
{...{ [UI_DATA_ATTRS.PICKER_INDEX]: dataIndex }}
{disabled}
{onclick}
{onmouseenter}
type="button"
{...{ [UI_DATA_ATTRS.PICKER_INDEX]: dataIndex }}
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/50 {isSelected
? 'bg-accent/50'
: ''} {disabled ? 'cursor-not-allowed opacity-50' : ''} {className}"
@@ -12,6 +12,7 @@
<!-- Server label skeleton -->
<div class="mb-2 flex items-center gap-1.5">
<div class="h-3 w-3 shrink-0 animate-pulse rounded-sm bg-muted"></div>
<div class="h-3 w-24 animate-pulse rounded bg-muted"></div>
</div>
@@ -30,21 +30,21 @@
}}
>
<Popover.Trigger
aria-hidden="true"
class="pointer-events-none absolute inset-0 opacity-0"
tabindex={-1}
aria-hidden="true"
>
<span class="sr-only">{srLabel}</span>
</Popover.Trigger>
<Popover.Content
side="top"
align="start"
sideOffset={12}
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
preventScroll={false}
onkeydown={onKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
onkeydown={onKeydown}
preventScroll={false}
side="top"
sideOffset={12}
>
{@render children()}
</Popover.Content>
@@ -104,34 +104,36 @@
<ChatFormPickerPopover
bind:isOpen
class={className}
srLabel="Open command picker"
{onClose}
onKeydown={handleKeydown}
srLabel="Open command picker"
>
<ChatFormPickerList
items={filteredCommands}
emptyMessage="No matching command"
isLoading={false}
itemKey={(command) => command.name}
items={filteredCommands}
scrollTrigger={nav.scrollTrigger}
searchQuery={query ?? ''}
selectedIndex={nav.hoveredIndex}
showSearchInput={false}
searchQuery={query ?? ''}
emptyMessage="No matching command"
itemKey={(command) => command.name}
scrollTrigger={nav.scrollTrigger}
>
{#snippet item(command, index, isSelected)}
{@const Icon = commandIcon[command.action]}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
disabled={command.disabled}
{isSelected}
onclick={() => handleSelect(command)}
onmouseenter={() => {
if (!command.disabled) nav.setHover(index);
}}
>
<Icon class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div class="flex min-w-0 flex-1 flex-col">
<span class="font-mono text-sm font-medium">/{command.name}</span>
<span class="min-w-0 flex-1 truncate text-left text-xs text-muted-foreground">
{command.description}
</span>
@@ -359,9 +359,9 @@
<ChatFormPickerPopover
bind:isOpen
class={className}
srLabel="Open prompt picker"
{onClose}
onKeydown={handleKeydown}
srLabel="Open prompt picker"
>
{#if selectedPrompt}
{@const prompt = selectedPrompt}
@@ -370,10 +370,10 @@
<div class="p-4">
<ChatFormPickerItemHeader
description={prompt.description}
{server}
{serverLabel}
title={prompt.title || prompt.name}
description={prompt.description}
>
{#snippet titleExtra()}
{#if prompt.arguments?.length}
@@ -385,33 +385,33 @@
</ChatFormPickerItemHeader>
<ChatFormPromptPickerArgumentForm
prompt={selectedPrompt}
{promptArgs}
{suggestions}
{loadingSuggestions}
{activeAutocomplete}
{autocompleteIndex}
{promptError}
onArgInput={handleArgInput}
onArgKeydown={handleArgKeydown}
{loadingSuggestions}
onArgBlur={handleArgBlur}
onArgFocus={handleArgFocus}
onArgInput={handleArgInput}
onArgKeydown={handleArgKeydown}
onCancel={handleCancelArgumentForm}
onSelectSuggestion={selectSuggestion}
onSubmit={handleArgumentSubmit}
onCancel={handleCancelArgumentForm}
prompt={selectedPrompt}
{promptArgs}
{promptError}
{suggestions}
/>
</div>
{:else}
<ChatFormPickerList
items={filteredPrompts}
{isLoading}
{selectedIndex}
bind:searchQuery={internalSearchQuery}
{showSearchInput}
searchPlaceholder="Search prompts..."
emptyMessage="No MCP prompts available"
{isLoading}
itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
items={filteredPrompts}
{scrollTrigger}
searchPlaceholder="Search prompts..."
{selectedIndex}
{showSearchInput}
>
{#snippet item(prompt, index, isSelected)}
{@const server = serverSettingsMap.get(prompt.serverName)}
@@ -423,10 +423,10 @@
onclick={() => handlePromptClick(prompt)}
>
<ChatFormPickerItemHeader
description={prompt.description}
{server}
{serverLabel}
title={prompt.title || prompt.name}
description={prompt.description}
>
{#snippet titleExtra()}
{#if prompt.arguments?.length}
@@ -440,7 +440,7 @@
{/snippet}
{#snippet skeleton()}
<ChatFormPickerListItemSkeleton titleWidth="w-32" showBadge />
<ChatFormPickerListItemSkeleton showBadge titleWidth="w-32" />
{/snippet}
</ChatFormPickerList>
{/if}
@@ -38,20 +38,20 @@
}: Props = $props();
</script>
<form onsubmit={onSubmit} class="space-y-3 pt-4">
<form class="space-y-3 pt-4" onsubmit={onSubmit}>
{#each prompt.arguments ?? [] as arg (arg.name)}
<ChatFormPromptPickerArgumentInput
argument={arg}
value={promptArgs[arg.name] ?? ''}
suggestions={suggestions[arg.name] ?? []}
isLoadingSuggestions={loadingSuggestions[arg.name] ?? false}
isAutocompleteActive={activeAutocomplete === arg.name}
autocompleteIndex={activeAutocomplete === arg.name ? autocompleteIndex : 0}
onInput={(value) => onArgInput(arg.name, value)}
onKeydown={(e) => onArgKeydown(e, arg.name)}
isAutocompleteActive={activeAutocomplete === arg.name}
isLoadingSuggestions={loadingSuggestions[arg.name] ?? false}
onBlur={() => onArgBlur(arg.name)}
onFocus={() => onArgFocus(arg.name)}
onInput={(value) => onArgInput(arg.name, value)}
onKeydown={(e) => onArgKeydown(e, arg.name)}
onSelectSuggestion={(value) => onSelectSuggestion(arg.name, value)}
suggestions={suggestions[arg.name] ?? []}
value={promptArgs[arg.name] ?? ''}
/>
{/each}
@@ -67,7 +67,7 @@
{/if}
<div class="mt-8 flex justify-end gap-2">
<Button type="button" size="sm" onclick={onCancel} variant="secondary">Cancel</Button>
<Button onclick={onCancel} size="sm" type="button" variant="secondary">Cancel</Button>
<Button size="sm" type="submit">Use Prompt</Button>
</div>
@@ -36,7 +36,7 @@
</script>
<div class="relative grid gap-1">
<Label for="arg-{argument.name}" class="mb-1 text-muted-foreground">
<Label class="mb-1 text-muted-foreground" for="arg-{argument.name}">
<span>
{argument.name}
@@ -51,30 +51,30 @@
</Label>
<Input
autocomplete="off"
id="arg-{argument.name}"
type="text"
{value}
oninput={(e) => onInput(e.currentTarget.value)}
onkeydown={onKeydown}
onblur={onBlur}
onfocus={onFocus}
oninput={(e) => onInput(e.currentTarget.value)}
onkeydown={onKeydown}
placeholder={argument.description || argument.name}
required={argument.required}
autocomplete="off"
type="text"
{value}
/>
{#if isAutocompleteActive && suggestions.length > 0}
<div
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
transition:fly={{ duration: 100, y: -5 }}
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
>
{#each suggestions as suggestion, i (suggestion)}
<button
type="button"
onmousedown={() => onSelectSuggestion(suggestion)}
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i === autocompleteIndex
? 'bg-accent'
: ''}"
onmousedown={() => onSelectSuggestion(suggestion)}
type="button"
>
{suggestion}
</button>
@@ -187,10 +187,10 @@
</script>
<Popover.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
open={isOpen}
>
<!-- Invisible form-wide trigger: stops bits-ui's outside-click detector
from closing the picker when the user clicks inside the textarea.
@@ -198,36 +198,36 @@
(tabindex=-1 + pointer-events-none + opacity-0 + aria-hidden).
Positioning comes from `customAnchor` at the form's top edge. -->
<Popover.Trigger
aria-hidden="true"
class="pointer-events-none absolute inset-0 opacity-0"
tabindex={-1}
aria-hidden="true"
>
<span class="sr-only">Open file mention picker</span>
</Popover.Trigger>
<Popover.Content
align="start"
side="top"
sideOffset={12}
{customAnchor}
preventScroll={false}
onkeydown={handleKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
class={[
'w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl',
className
]}
{customAnchor}
onCloseAutoFocus={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => event.preventDefault()}
onkeydown={handleKeydown}
preventScroll={false}
side="top"
sideOffset={12}
>
<ChatFormPickerList
items={displayedItems}
{emptyMessage}
isLoading={search.isSearching}
itemKey={(entry) => entry.type + ':' + entry.path}
items={displayedItems}
scrollTrigger={nav.scrollTrigger}
searchQuery={query ?? ''}
selectedIndex={nav.hoveredIndex}
showSearchInput={false}
searchQuery={query ?? ''}
{emptyMessage}
itemKey={(entry) => entry.type + ':' + entry.path}
scrollTrigger={nav.scrollTrigger}
>
{#snippet item(entry, index, isSelected)}
<ChatFormPickerListItem
@@ -245,6 +245,7 @@
: 'text-muted-foreground'
]}
/>
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-w-0 items-center gap-2">
{#if showTooltip}
@@ -254,6 +255,7 @@
<span {...props} class="truncate text-sm font-medium">{entry.name}</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{entry.path}</p>
</Tooltip.Content>
@@ -261,14 +263,16 @@
{:else}
<span class="truncate text-sm font-medium">{entry.name}</span>
{/if}
<span
class="shrink-0 rounded-full bg-muted px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-muted-foreground"
>
{entry.type}
</span>
</div>
<span class="min-w-0 flex-1 truncate font-mono text-left text-xs">
<HighlightedMatch text={abbreviateHome(entry.path, home)} query={trimmedQuery} />
<HighlightedMatch query={trimmedQuery} text={abbreviateHome(entry.path, home)} />
</span>
</div>
</ChatFormPickerListItem>
@@ -79,30 +79,30 @@
<ChatFormPickerCommand
bind:this={commandPickerRef}
isOpen={isCommandPickerOpen ?? false}
query={commandQuery ?? ''}
{commands}
isOpen={isCommandPickerOpen ?? false}
onClose={onCommandPickerClose ?? (() => {})}
onSelect={onCommandSelect ?? (() => {})}
query={commandQuery ?? ''}
/>
<ChatFormPickerMcpPrompts
bind:this={promptPickerRef}
isOpen={isPromptPickerOpen}
searchQuery={promptSearchQuery}
onClose={onPromptPickerClose}
{onPromptLoadStart}
{onPromptLoadComplete}
{onPromptLoadError}
{onPromptLoadStart}
searchQuery={promptSearchQuery}
/>
<ChatFormPickerMention
bind:this={mentionPickerRef}
isOpen={isMentionPickerOpen ?? false}
query={mentionQuery ?? ''}
customAnchor={mentionAnchor}
scopePath={scopePath ?? null}
isOpen={isMentionPickerOpen ?? false}
onClose={onMentionPickerClose ?? (() => {})}
onOpened={onMentionOpened}
onSelect={onMentionSelect ?? (() => {})}
query={mentionQuery ?? ''}
scopePath={scopePath ?? null}
/>
@@ -381,13 +381,13 @@
}
</script>
<div class="chat-message" class:chat-message--synthetic={isSynthetic}>
<div class:chat-message--synthetic={isSynthetic} class="chat-message">
{#if message.role === MessageRole.SYSTEM}
<ChatMessageSystem bind:textareaElement class={className} {message} />
{:else if mcpPromptExtra}
<ChatMessageMcpPrompt class={className} {message} mcpPrompt={mcpPromptExtra} />
<ChatMessageMcpPrompt class={className} mcpPrompt={mcpPromptExtra} {message} />
{:else if isSynthetic}
<ChatMessageSynthetic {message} class={className} />
<ChatMessageSynthetic class={className} {message} />
{:else if message.role === MessageRole.USER}
<ChatMessageUser class={className} {isLastUserMessage} {message} {nextAssistantMessage} />
{:else}
@@ -396,9 +396,9 @@
class={className}
{isLastAssistantMessage}
{message}
{toolMessages}
onContinue={handleContinue}
onRegenerate={handleRegenerate}
{toolMessages}
/>
{/if}
</div>
@@ -126,16 +126,16 @@
<div
bind:this={assistantEl}
class="chat-message-assistant text-md group w-full leading-7.5 {className}"
style:--assistant-margin-top={assistantMarginTop > 0 ? `${assistantMarginTop}px` : undefined}
style:--last-user-message-height={lastUserMessageHeight > 0
? `${lastUserMessageHeight}px`
: undefined}
style:--assistant-margin-top={assistantMarginTop > 0 ? `${assistantMarginTop}px` : undefined}
role="group"
aria-label="Assistant message with actions"
class="chat-message-assistant text-md group w-full leading-7.5 {className}"
role="group"
>
{#if showProcessingInfoTop}
<ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="top" />
<ChatMessageAssistantProcessingInfo {modelLoadingText} position="top" {processingState} />
{/if}
{#if editCtx.isEditing}
@@ -145,16 +145,16 @@
<ChatMessageAssistantRawOutput {message} {toolMessages} />
{:else}
<ChatMessageAgenticContent
{isLastAssistantMessage}
isStreaming={chatStore.isStreaming()}
{message}
{toolMessages}
isStreaming={chatStore.isStreaming()}
{isLastAssistantMessage}
/>
{/if}
{/if}
{#if showProcessingInfoBottom}
<ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="bottom" />
<ChatMessageAssistantProcessingInfo {modelLoadingText} position="bottom" {processingState} />
{/if}
{#if displayedModel}
@@ -168,8 +168,8 @@
/>
<ChatMessageAssistantStatistics
{message}
isLoading={chatStore.isLoading}
{message}
{processingState}
showMessageStats={currentConfig.showMessageStats}
/>
@@ -179,14 +179,14 @@
{#if message.timestamp && !editCtx.isEditing}
<ChatMessageActionIcons
role={MessageRole.ASSISTANT}
justify="start"
actionsPosition="left"
{onRegenerate}
justify="start"
onContinue={currentConfig.enableContinueGeneration ? onContinue : undefined}
showRawOutputSwitch={currentConfig.showRawOutputSwitch}
rawOutputEnabled={showRawOutput}
onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
{onRegenerate}
rawOutputEnabled={showRawOutput}
role={MessageRole.ASSISTANT}
showRawOutputSwitch={currentConfig.showRawOutputSwitch}
/>
{/if}
</div>
@@ -13,7 +13,7 @@
const marginClass = $derived(position === 'top' ? 'mt-6' : 'mt-4');
</script>
<div class="{marginClass} w-full max-w-3xl" in:fade>
<div in:fade class="{marginClass} w-full max-w-3xl">
<div class="flex flex-col items-start gap-2">
<span class="shimmer-text text-sm">
{modelLoadingText ??
@@ -24,22 +24,22 @@
{#if showMessageStats && isLiveFlowRoot && liveLlm}
<ChatMessageStatistics
mode={ChatMessageStatisticsMode.GENERATION}
isLive
promptTokens={liveLlm.prompt_n}
promptMs={liveLlm.prompt_ms}
predictedTokens={liveLlm.predicted_n}
mode={ChatMessageStatisticsMode.GENERATION}
predictedMs={liveLlm.predicted_ms}
predictedTokens={liveLlm.predicted_n}
promptMs={liveLlm.prompt_ms}
promptTokens={liveLlm.prompt_n}
/>
{:else if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
{@const agentic = message.timings.agentic}
<ChatMessageStatistics
mode={ChatMessageStatisticsMode.GENERATION}
promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
agenticTimings={agentic}
mode={ChatMessageStatisticsMode.GENERATION}
predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
/>
{:else if isLoading && showMessageStats}
{@const liveStats = processingState.getLiveProcessingStats()}
@@ -47,12 +47,12 @@
{#if genStats}
<ChatMessageStatistics
mode={ChatMessageStatisticsMode.GENERATION}
isLive
promptTokens={liveStats?.tokensProcessed}
promptMs={liveStats?.timeMs}
predictedTokens={genStats.tokensGenerated}
mode={ChatMessageStatisticsMode.GENERATION}
predictedMs={genStats.timeMs}
predictedTokens={genStats.tokensGenerated}
promptMs={liveStats?.timeMs}
promptTokens={liveStats?.tokensProcessed}
/>
{/if}
{/if}
@@ -19,10 +19,13 @@
<div class="text-muted-foreground flex items-center gap-2 py-1.5 {className}">
{#if info.path === null}
<FolderX class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
<span class="text-foreground/80 text-sm font-medium">Working directory cleared</span>
{:else}
<Folder class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
<span class="text-foreground/80 text-sm font-medium">Set working directory to&nbsp;</span>
<span class="font-mono text-foreground/90 text-sm break-all" title={info.path}>
{info.display}
</span>
@@ -29,9 +29,9 @@
<ChatMessageEditForm />
{:else}
<ChatMessageMcpPromptContent
class="w-full max-w-[80%]"
prompt={mcpPrompt}
variant={McpPromptVariant.MESSAGE}
class="w-full max-w-[80%]"
/>
{#if message.timestamp}
@@ -99,12 +99,12 @@
<Tooltip.Trigger>
{#if serverFavicon}
<img
src={serverFavicon}
alt=""
class="h-3.5 w-3.5 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={serverFavicon}
/>
{/if}
</Tooltip.Trigger>
@@ -17,7 +17,7 @@
</script>
{#if isCwdChange}
<ChatMessageCwdChange {message} class={className} />
<ChatMessageCwdChange class={className} {message} />
{:else}
<span class="text-muted-foreground block text-sm {className}">{message.content}</span>
{/if}
@@ -83,16 +83,16 @@
{#if editCtx.isEditing}
<div class="w-full max-w-[80%]">
<textarea
style="max-height: var(--max-message-height);"
bind:this={textareaElement}
value={editCtx.editedContent}
class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
onkeydown={handleEditKeydown}
oninput={(e) => {
autoResizeTextarea(e.currentTarget);
editCtx.setContent(e.currentTarget.value);
}}
onkeydown={handleEditKeydown}
placeholder="Edit system message..."
style="max-height: var(--max-message-height);"
value={editCtx.editedContent}
></textarea>
<div class="mt-2 flex justify-end gap-2">
@@ -104,8 +104,8 @@
<Button
class="h-8 px-3"
onclick={editCtx.save}
disabled={!editCtx.editedContent.trim()}
onclick={editCtx.save}
size="sm"
>
<Check class="mr-1 h-3 w-3" />
@@ -34,34 +34,34 @@
</script>
{#if isSearchCall}
<ChatMessageToolCallBlockSearchResults {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockSearchResults {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.BROWSER_GET_DATETIME}
<ChatMessageToolCallBlockGetDatetime {section} {isStreaming} />
<ChatMessageToolCallBlockGetDatetime {isStreaming} {section} />
{:else if section.toolName === BuiltInTool.SERVER_GET_INFO}
<ChatMessageToolCallBlockGetInfo {section} {isStreaming} />
<ChatMessageToolCallBlockGetInfo {isStreaming} {section} />
{:else if section.toolName === BuiltInTool.SERVER_READ_FILE}
<ChatMessageToolCallBlockReadFile {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockReadFile {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.BROWSER_READ_MEDIA}
<ChatMessageToolCallBlockReadMedia {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockReadMedia {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.SERVER_EDIT_FILE}
<ChatMessageToolCallBlockEditFile {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockEditFile {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.SERVER_WRITE_FILE}
<ChatMessageToolCallBlockWriteFile {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockWriteFile {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.SERVER_EXEC_SHELL_COMMAND}
<ChatMessageToolCallBlockExecShellCommand
{section}
{open}
{isStreaming}
{isExecuting}
{attachments}
{isExecuting}
{isStreaming}
{onToggle}
{open}
{section}
/>
{:else if section.toolName === BuiltInTool.SERVER_FILE_GLOB_SEARCH}
<ChatMessageToolCallBlockFileGlobSearch {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockFileGlobSearch {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.SERVER_GREP_SEARCH}
<ChatMessageToolCallBlockGrepSearch {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockGrepSearch {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.BROWSER_RUN_JAVASCRIPT}
<ChatMessageToolCallBlockRunJavascript {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockRunJavascript {isStreaming} {onToggle} {open} {section} />
{:else}
<ChatMessageToolCallBlockDefault {section} {open} {isStreaming} {attachments} {onToggle} />
<ChatMessageToolCallBlockDefault {attachments} {isStreaming} {onToggle} {open} {section} />
{/if}
@@ -34,15 +34,17 @@
);
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={null} {title} {onToggle}>
<ToolCallBlock {isStreaming} meta={null} {onToggle} {open} {section} {title}>
{#snippet children(_meta, ctx)}
{#if ctx.isStreamingCall}
<div class="mb-2 flex items-center gap-2 text-xs text-muted-foreground/70">
<span>Input</span>
{#if ctx.isStreaming}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
</div>
{#if section.toolArgs}
<SyntaxHighlightedCode
code={formatJsonPretty(section.toolArgs)}
@@ -67,6 +69,7 @@
<div class="mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70">
<span>Input</span>
</div>
<SyntaxHighlightedCode
code={formatJsonPretty(section.toolArgs ?? '')}
language={FileTypeText.JSON}
@@ -74,16 +77,19 @@
streaming={ctx.isCodeStreaming}
/>
{/if}
<div
class={showInput
? 'mt-4 mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70'
: 'mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70'}
>
<span>Output</span>
{#if ctx.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
</div>
{#if ctx.isPending}
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
Waiting for result...
@@ -96,18 +102,19 @@
maxHeight={MAX_HEIGHT_CODE_BLOCK}
/>
{:else if outputKind === ToolResultKind.MARKDOWN}
<MarkdownContent content={section.toolResult} {attachments} />
<MarkdownContent {attachments} content={section.toolResult} />
{:else}
<div class="overflow-auto">
{#each parsedLines as line, i (i)}
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">
{line.text}
</div>
{#if line.media}
{#if line.media.type === AttachmentType.AUDIO}
{@const audioMimeType = line.media.mimeType ?? MimeTypeAudio.MP3_MPEG}
<div class="mt-2 mb-2">
<audio controls class="w-full rounded-lg">
<audio class="w-full rounded-lg" controls>
<source
src={createBase64DataUrl(audioMimeType, line.media.base64Data)}
type={audioMimeType}
@@ -117,10 +124,10 @@
</div>
{:else}
<img
src={line.media.base64Url}
alt={line.media.name}
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
loading="lazy"
src={line.media.base64Url}
/>
{/if}
{/if}
@@ -23,12 +23,14 @@
);
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={editFileMeta} {onToggle}>
<ToolCallBlock {isStreaming} meta={editFileMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
<span class="text-muted-foreground">Edit file </span>
<span class="font-mono" title={editFileMeta?.filePath}
>{abbreviateHome(editFileMeta?.filePath ?? '', home)}</span
>
{#if editFileMeta?.errorMessage}
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
{/if}
@@ -40,6 +42,7 @@
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
>
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
<span>{meta.errorMessage}</span>
</div>
{:else if meta && meta.edits.length > 0}
@@ -48,13 +51,17 @@
<div class="mb-1.5 text-xs text-muted-foreground/70 italic">
Edit {ei + 1}&nbsp;of&nbsp;{meta.edits.length}
</div>
<div class="diff-block" style:max-height={MAX_HEIGHT_CODE_BLOCK}>
<div style:max-height={MAX_HEIGHT_CODE_BLOCK} class="diff-block">
<div class="diff-pre">
{#each diffLines as line, li (li)}
<div class="diff-line diff-{line.kind}">
<span class="diff-old-num">{line.oldLine ?? ''}</span>
<span class="diff-marker">{prefixFor(line.kind)}</span>
<span class="diff-new-num">{line.newLine ?? ''}</span>
<span class="diff-text">{line.text || ' '}</span>
</div>
{/each}
@@ -62,9 +69,11 @@
</div>
</div>
{/each}
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
{#if meta.resultMessage}
{meta.resultMessage}{meta.editsApplied != null ? RESULT_STAT_SEPARATOR : ''}{/if}
{#if meta.editsApplied != null}
<span class="font-mono">{meta.editsApplied}</span>
{meta.editsApplied === 1 ? 'edit' : 'edits'}&nbsp;applied
@@ -176,6 +176,7 @@
{#snippet execShellTitle()}
{#if cwd}
<span class="exec-wd" title={cwd}>{wdDisplay}</span>
<span class="exec-prompt">$</span>
{/if}
@@ -187,14 +188,14 @@
{/snippet}
<ToolCallBlock
{section}
{open}
extraLiveStreaming={isLive}
{isStreaming}
meta={execShellMeta ? { errorMessage: execShellError } : null}
wrapper={CollapsibleTerminalBlock}
extraLiveStreaming={isLive}
spinIconWhenActive={true}
{onToggle}
{open}
{section}
spinIconWhenActive={true}
wrapper={CollapsibleTerminalBlock}
>
{#snippet titleSnippet()}
{@render execShellTitle()}
@@ -209,23 +210,25 @@
{:else if execShellError}
<div class="flex items-start gap-2 text-xs text-red-600 italic dark:text-red-400">
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
<span>{execShellError}</span>
</div>
{:else if section.toolResult}
<div
bind:this={scrollEl}
class="terminal-output"
class:is-clamped={!useFullHeightCodeBlocks}
class="terminal-output"
onscroll={handleScrollEvent}
>
{#each outputLines as line, i (i)}
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{line.text}</div>
{#if line.media?.type === AttachmentType.IMAGE}
<img
src={line.media.base64Url}
alt={line.media.name}
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
loading="lazy"
src={line.media.base64Url}
/>
{/if}
{/each}
@@ -234,14 +237,19 @@
<div class={exitBadgeClass}>
{#if execShellExitStatus.timedOut}
<AlertTriangle class="h-3 w-3" />
<span>timed out</span>
<span class="exit-sep">&middot;</span>
<span>exit {execShellExitStatus.code}</span>
{:else if execShellExitStatus.code === 0}
<Check class="h-3 w-3" />
<span>exit 0</span>
{:else}
<XCircle class="h-3 w-3" />
<span>exit {execShellExitStatus.code}</span>
{/if}
</div>
@@ -19,16 +19,19 @@
const home = $derived(toolsStore.serverHome);
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={fileGlobMeta} {onToggle}>
<ToolCallBlock {isStreaming} meta={fileGlobMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
{#if fileGlobMeta}
<span class="text-muted-foreground"
>{fileGlobMeta.include === '**' ? 'List files' : 'Search files'}&nbsp;</span
>
{#if fileGlobMeta.include !== '**'}
<span class="font-mono">{fileGlobMeta.include}</span>
{/if}
<span class="text-muted-foreground">&nbsp;in&nbsp;</span>
<span class="font-mono" title={fileGlobMeta.path}
>{abbreviateHome(fileGlobMeta.path, home)}</span
>
@@ -45,6 +48,7 @@
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
>
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
<span>{meta.errorMessage}</span>
</div>
{:else if meta && meta.matches.length > 0}
@@ -53,11 +57,13 @@
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{match}</div>
{/each}
</div>
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
Total matches: <span class="font-mono">{meta.totalMatches ?? meta.matches.length}</span>
</div>
{:else}
<div class="text-xs text-muted-foreground/70 italic">No matches</div>
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
Total matches: <span class="font-mono">{meta?.totalMatches ?? 0}</span>
</div>
@@ -44,15 +44,19 @@
<div class="text-muted-foreground flex items-center gap-2 py-1.5">
<Clock class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
{#if showSpinner}
<span class="text-foreground/80 text-sm font-medium">Current time</span>
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
{:else if dateMeta.errorMessage}
<span class="text-foreground/80 text-sm font-medium">Current time&nbsp;</span>
<span class="text-red-600 text-xs italic dark:text-red-400">-&nbsp;{dateMeta.errorMessage}</span
>
{:else if dateMeta.dateString}
<span class="text-foreground/80 text-sm font-medium">Current time is&nbsp;</span>
<span class="font-mono text-foreground/90 text-sm">{dateMeta.dateString}</span>
{:else}
<span class="text-foreground/80 text-sm font-medium">Current time</span>
@@ -52,18 +52,23 @@
<div class="text-muted-foreground flex items-center gap-2 py-1.5">
<Info class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
{#if showSpinner}
<span class="text-foreground/80 text-sm font-medium">Runtime info</span>
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
{:else if infoMeta.errorMessage}
<span class="text-foreground/80 text-sm font-medium">Runtime info&nbsp;</span>
<span class="text-red-600 text-xs italic dark:text-red-400">-&nbsp;{infoMeta.errorMessage}</span
>
{:else if infoMeta.os || infoMeta.cwd}
<span class="text-foreground/80 text-sm font-medium">Runtime info&nbsp;</span>
{#if infoMeta.os}
<span class="font-mono text-foreground/90 text-sm">{infoMeta.os}</span>
{/if}
{#if infoMeta.cwd}
<span class="font-mono text-foreground/90 text-sm" title={infoMeta.cwd}>{cwdDisplay}</span>
{/if}
@@ -19,12 +19,15 @@
const home = $derived(toolsStore.serverHome);
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={grepMeta} {onToggle}>
<ToolCallBlock {isStreaming} meta={grepMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
{#if grepMeta}
<span class="text-muted-foreground">Search for&nbsp;</span>
<span class="font-mono">{grepMeta.pattern}</span>
<span class="text-muted-foreground">&nbsp;in&nbsp;</span>
<span class="font-mono" title={grepMeta.path}>{abbreviateHome(grepMeta.path, home)}</span>
{/if}
{/snippet}
@@ -39,6 +42,7 @@
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
>
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
<span>{meta.errorMessage}</span>
</div>
{:else if meta && meta.matches.length > 0}
@@ -46,22 +50,28 @@
{#each meta.matches as match, mi (mi)}
<div class="font-mono text-[11px] leading-relaxed">
<span class="text-muted-foreground/70">{match.file}</span>
{#if meta.showLineNumbers && match.line != null}
<span class="text-muted-foreground/70">:{match.line}</span>
{/if}
<span class="text-muted-foreground/70">:</span>
<span>{match.content}</span>
</div>
{/each}
</div>
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
Total matches: <span class="font-mono">{meta.totalMatches ?? meta.matches.length}</span>
{#if meta.showLineNumbers}
&nbsp;<span class="italic">(with line numbers)</span>
{/if}
</div>
{:else}
<div class="text-xs text-muted-foreground/70 italic">No matches</div>
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
Total matches: <span class="font-mono">{meta?.totalMatches ?? 0}</span>
</div>
@@ -17,10 +17,12 @@
const readFileMeta = $derived(parseReadFileMeta(section));
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={readFileMeta} {onToggle}>
<ToolCallBlock {isStreaming} meta={readFileMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
<span class="text-muted-foreground">Read file </span>
<span class="font-mono">{readFileMeta?.fileName}</span>
{#if readFileMeta?.lineRange}
<span class="text-muted-foreground"
>&nbsp;(lines {readFileMeta.lineRange.start}-{readFileMeta.lineRange.end})</span
@@ -43,9 +43,10 @@
const audioMimeType = $derived(readMediaMeta?.mimeType ?? MimeTypeAudio.MP3_MPEG);
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={readMediaMeta} {onToggle}>
<ToolCallBlock {isStreaming} meta={readMediaMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
<span class="text-muted-foreground">Read media </span>
<span class="font-mono">{readMediaMeta?.fileName}</span>
{/snippet}
@@ -57,7 +58,7 @@
</div>
{:else if mediaAttachment.type === AttachmentType.AUDIO}
<div class="mt-2">
<audio controls class="w-full rounded-lg">
<audio class="w-full rounded-lg" controls>
<source
src={createBase64DataUrl(audioMimeType, mediaAttachment.base64Data)}
type={audioMimeType}
@@ -68,10 +69,10 @@
{:else}
<div class="mt-2">
<img
src={mediaAttachment.base64Url}
alt={readMediaMeta?.fileName ?? 'media'}
class="max-h-[60vh] max-w-full rounded-lg object-contain shadow-lg"
loading="lazy"
src={mediaAttachment.base64Url}
/>
</div>
{/if}
@@ -81,6 +82,7 @@
{#if readMediaMeta?.sizeBytes}
<span>Size: {readMediaMeta.sizeBytes} bytes</span>
{/if}
{#if readMediaMeta?.mimeType}
<span>MIME: {readMediaMeta.mimeType}</span>
{/if}
@@ -21,7 +21,7 @@
const title = $derived(getToolUi(section.toolName)?.label ?? section.toolName ?? '');
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={runJsMeta} {title} {onToggle}>
<ToolCallBlock {isStreaming} meta={runJsMeta} {onToggle} {open} {section} {title}>
{#snippet children(meta, ctx)}
{#if ctx.isPending}
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">Running...</div>
@@ -30,8 +30,10 @@
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
>
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
<span>{meta.errorMessage}</span>
</div>
<div class="mt-3">
<SyntaxHighlightedCode
code={meta.code}
@@ -47,13 +49,17 @@
maxHeight={MAX_HEIGHT_CODE_BLOCK}
streaming={ctx.isCodeStreaming}
/>
<div class="mb-2 mt-3 flex items-center gap-2 text-xs text-muted-foreground/70">
<Terminal class="h-3 w-3" />
<span>Console</span>
{#if meta.timeoutMs != null}
<span class="font-mono">&middot;&nbsp;timeout&nbsp;{meta.timeoutMs}&nbsp;ms</span>
{/if}
</div>
{#if section.toolResult}
<div class="mt-1">
<SyntaxHighlightedCode
@@ -86,55 +86,60 @@
{@const safeUrl = sanitizeExternalUrl(result.url)}
{@const showHoverCard = safeUrl !== null && hasDetails(result)}
{#if safeUrl}
<HoverCard.Root openDelay={150} closeDelay={100}>
<HoverCard.Root closeDelay={100} openDelay={150}>
<HoverCard.Trigger
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
class="hover:bg-muted/80 focus-visible:ring-ring inline-flex max-w-full items-center gap-1.5 rounded-full border bg-muted px-2.5 py-1 text-xs transition-colors outline-none focus-visible:ring-2"
href={safeUrl}
rel="noopener noreferrer"
target="_blank"
>
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={hideBrokenIcon}
src={faviconUrl}
/>
{:else}
<Globe class="text-muted-foreground/70 h-3 w-3 shrink-0" />
{/if}
<span class="truncate font-medium text-foreground/80">{result.title}</span>
</HoverCard.Trigger>
{#if showHoverCard}
{@const publishDate = formatPublishDate(result.published)}
{@const host = hostFor(safeUrl)}
<HoverCard.Content
side="top"
align="start"
sideOffset={6}
class="bg-popover text-popover-foreground z-50 w-80 max-w-[90vw] rounded-lg border p-0 shadow-lg"
side="top"
sideOffset={6}
>
<div class="flex flex-col gap-2 p-3">
<a
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
class="line-clamp-3 text-sm font-medium leading-snug hover:underline"
>{result.title}</a
href={safeUrl}
rel="noopener noreferrer"
target="_blank">{result.title}</a
>
{#if publishDate || result.author}
<div class="text-muted-foreground flex items-center gap-1.5 text-[11px]">
{#if publishDate}
<span>{publishDate}</span>
{/if}
{#if publishDate && result.author}
<span class="opacity-50">&middot;</span>
{/if}
{#if result.author}
<span class="truncate">{result.author}</span>
{/if}
</div>
{/if}
{#if result.highlights}
<p
class="text-popover-foreground/85 line-clamp-5 text-xs leading-relaxed whitespace-pre-line"
@@ -142,6 +147,7 @@
{result.highlights}
</p>
{/if}
{#if host}
<div class="text-muted-foreground/80 truncate text-[11px]">{host}</div>
{/if}
@@ -152,7 +158,7 @@
{/if}
{/snippet}
<CollapsibleContentBlock {open} class="my-2" {icon} {iconClass} {iconUrl} {title} {onToggle}>
<CollapsibleContentBlock class="my-2" {icon} {iconClass} {iconUrl} {onToggle} {open} {title}>
{#if results.length > 0}
<div class="flex flex-wrap items-center gap-2 pb-1">
{#each results as result (result.url)}
@@ -162,6 +168,7 @@
{:else if showSpinner}
<div class="text-muted-foreground/70 flex items-center gap-2 py-1 text-xs italic">
<Loader2 class="h-3 w-3 animate-spin" />
<span>Searching...</span>
</div>
{:else}
@@ -21,12 +21,14 @@
const home = $derived(toolsStore.serverHome);
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={writeFileMeta} {onToggle}>
<ToolCallBlock {isStreaming} meta={writeFileMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
<span class="text-muted-foreground">Write file </span>
<span class="font-mono" title={writeFileMeta?.filePath}
>{abbreviateHome(writeFileMeta?.filePath ?? '', home)}</span
>
{#if writeFileMeta?.errorMessage}
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
{/if}
@@ -38,6 +40,7 @@
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
>
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
<span>{meta.errorMessage}</span>
</div>
{:else if meta}
@@ -47,9 +50,11 @@
maxHeight={MAX_HEIGHT_CODE_BLOCK}
streaming={ctx.isCodeStreaming}
/>
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
{#if meta.resultMessage}
{meta.resultMessage}{meta.bytesWritten != null ? RESULT_STAT_SEPARATOR : ''}{/if}
{#if meta.bytesWritten != null}
<span class="font-mono">{meta.bytesWritten}</span>
bytes
@@ -1,4 +1,4 @@
<script lang="ts" generics="TMeta">
<script generics="TMeta" lang="ts">
// Generic chrome shell shared by every per-tool block under
// `ChatMessageToolCall/`. Owns:
// - the collapsible wrapper (defaults to CollapsibleContentBlock;
@@ -114,15 +114,15 @@
</script>
<Wrapper
{open}
class="my-2"
icon={toolIcon}
iconClass={toolIconClass}
{iconUrl}
{onToggle}
{open}
{subtitle}
{title}
{titleSnippet}
{subtitle}
{onToggle}
>
{@render children(meta, {
isCodeStreaming,
@@ -69,8 +69,8 @@
<ChatMessageEditForm />
{:else}
<ChatMessageUserBubble
content={message.content}
attachments={message.extra}
content={message.content}
renderMarkdown={true}
/>
@@ -82,8 +82,8 @@
>
<ChatMessageStatistics
mode={ChatMessageStatisticsMode.READING}
promptTokens={storedReadingStats!.promptTokens}
promptMs={storedReadingStats!.promptMs}
promptTokens={storedReadingStats!.promptTokens}
/>
</div>
</div>
@@ -95,10 +95,10 @@
class="inline-flex flex-wrap items-start justify-end gap-2 text-xs text-muted-foreground"
>
<ChatMessageStatistics
mode={ChatMessageStatisticsMode.READING}
isLive
promptTokens={liveStats.tokensProcessed}
mode={ChatMessageStatisticsMode.READING}
promptMs={liveStats.timeMs}
promptTokens={liveStats.tokensProcessed}
/>
</div>
</div>
@@ -54,7 +54,7 @@
{#if attachments && attachments.length > 0}
<div class="mb-2 max-w-[80%]">
<ChatAttachmentsList {attachments} readonly imageHeight="h-40" />
<ChatAttachmentsList {attachments} imageHeight="h-40" readonly />
</div>
{/if}
@@ -37,11 +37,11 @@
<ChatMessageEditForm />
{:else}
<ChatMessageUserBubble
{content}
attachments={extras}
textColorClass="text-muted-foreground"
cardBgClass="dark:bg-primary/8"
{content}
maxHeightStyle="overflow-wrap: anywhere; word-break: break-word;"
textColorClass="text-muted-foreground"
/>
<div class="max-w-[80%]">
@@ -50,9 +50,11 @@
<div
class="pointer-events-auto inset-0 flex items-center gap-1 opacity-0 transition-all duration-150 group-hover:opacity-100"
>
<ActionIcon icon={Edit} tooltip="Edit" onclick={editCtx.handleEdit} />
<ActionIcon icon={Trash2} tooltip="Delete" onclick={onDelete} />
<ActionIcon icon={ArrowUp} tooltip="Send immediately" onclick={onSendImmediately} />
<ActionIcon icon={Edit} onclick={editCtx.handleEdit} tooltip="Edit" />
<ActionIcon icon={Trash2} onclick={onDelete} tooltip="Delete" />
<ActionIcon icon={ArrowUp} onclick={onSendImmediately} tooltip="Send immediately" />
</div>
</div>
</div>
@@ -14,10 +14,12 @@
<div class="my-2 rounded-lg border border-border bg-card p-3">
<div class="mb-3 flex items-center gap-2 text-sm">
<IconComponent class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
<span>
{@render message()}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
{@render actions()}
</div>
@@ -16,13 +16,13 @@
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={() => onDecision(true)}>Continue</Button>
<Button onclick={() => onDecision(true)} size="sm">Continue</Button>
<Button
variant="destructive"
size="sm"
class="text-destructive hover:text-destructive"
onclick={() => onDecision(false)}
size="sm"
variant="destructive"
>
Stop
</Button>
@@ -28,10 +28,10 @@
<DropdownMenu.Root>
<ButtonGroup.Root class="overflow-hidden rounded-md shadow-sm">
<Button
variant="secondary"
size="sm"
class="!rounded-r-none !shadow-none"
onclick={() => onDecision(ToolPermissionDecision.ONCE)}
size="sm"
variant="secondary"
>
Allow once
</Button>
@@ -39,11 +39,11 @@
<ButtonGroup.Separator />
<DropdownMenu.Trigger
aria-label="More allow options"
class={cn(
buttonVariants({ size: 'sm', variant: 'secondary' }),
'inline-flex cursor-pointer items-center !rounded-l-none !shadow-none !px-2'
)}
aria-label="More allow options"
>
<ChevronDown class="h-3.5 w-3.5" />
</DropdownMenu.Trigger>
@@ -54,6 +54,7 @@
Always allow <pre>{toolName}</pre>
tool
</DropdownMenu.Item>
{#if serverLabel}
<DropdownMenu.Item onclick={() => onDecision(ToolPermissionDecision.ALWAYS_SERVER)}>
Always allow all tools from {serverLabel}
@@ -73,7 +74,7 @@
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button variant="destructive" size="sm" onclick={() => onDecision(ToolPermissionDecision.DENY)}>
<Button onclick={() => onDecision(ToolPermissionDecision.DENY)} size="sm" variant="destructive">
Deny
</Button>
{/snippet}
@@ -77,29 +77,30 @@
<div
class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150"
>
<ActionIcon icon={Copy} tooltip="Copy" onclick={messageActions.copy} />
<ActionIcon icon={Copy} onclick={messageActions.copy} tooltip="Copy" />
<ActionIcon icon={Edit} tooltip="Edit" onclick={editCtx.startEdit} />
<ActionIcon icon={Edit} onclick={editCtx.startEdit} tooltip="Edit" />
{#if role === MessageRole.ASSISTANT && onRegenerate}
<ActionIcon icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
<ActionIcon icon={RefreshCw} onclick={() => onRegenerate()} tooltip="Regenerate" />
{/if}
{#if role === MessageRole.ASSISTANT && onContinue}
<ActionIcon icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
<ActionIcon icon={ArrowRight} onclick={onContinue} tooltip="Continue" />
{/if}
{#if messageActions.forkConversation}
<ActionIcon icon={GitBranch} tooltip="Fork conversation" onclick={handleOpenForkDialog} />
<ActionIcon icon={GitBranch} onclick={handleOpenForkDialog} tooltip="Fork conversation" />
{/if}
<ActionIcon icon={Trash2} tooltip="Delete" onclick={messageActions.requestDelete} />
<ActionIcon icon={Trash2} onclick={messageActions.requestDelete} tooltip="Delete" />
</div>
</div>
{#if showRawOutputSwitch}
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">Show raw output</span>
<Switch
checked={rawOutputEnabled}
onCheckedChange={(checked) => onRawOutputToggle?.(checked)}
@@ -109,54 +110,54 @@
</div>
<DialogConfirmation
open={messageActions.showDeleteDialog}
title="Delete Message"
description={messageActions.deletionInfo && messageActions.deletionInfo.totalCount > 1
? `This will delete ${messageActions.deletionInfo.totalCount} messages including: ${messageActions.deletionInfo.userMessages} user message${messageActions.deletionInfo.userMessages > 1 ? 's' : ''} and ${messageActions.deletionInfo.assistantMessages} assistant response${messageActions.deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.`
: 'Are you sure you want to delete this message? This action cannot be undone.'}
cancelText="Cancel"
confirmText={messageActions.deletionInfo && messageActions.deletionInfo.totalCount > 1
? `Delete ${messageActions.deletionInfo.totalCount} Messages`
: 'Delete'}
cancelText="Cancel"
variant="destructive"
description={messageActions.deletionInfo && messageActions.deletionInfo.totalCount > 1
? `This will delete ${messageActions.deletionInfo.totalCount} messages including: ${messageActions.deletionInfo.userMessages} user message${messageActions.deletionInfo.userMessages > 1 ? 's' : ''} and ${messageActions.deletionInfo.assistantMessages} assistant response${messageActions.deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.`
: 'Are you sure you want to delete this message? This action cannot be undone.'}
icon={Trash2}
onConfirm={handleConfirmDelete}
onCancel={() => messageActions.setShowDeleteDialog(false)}
onConfirm={handleConfirmDelete}
open={messageActions.showDeleteDialog}
title="Delete Message"
variant="destructive"
/>
<DialogConfirmation
bind:open={showForkDialog}
title="Fork Conversation"
description="Create a new conversation branching from this message."
confirmText="Fork"
cancelText="Cancel"
confirmText="Fork"
description="Create a new conversation branching from this message."
icon={GitBranch}
onConfirm={handleConfirmFork}
onCancel={() => (showForkDialog = false)}
onConfirm={handleConfirmFork}
title="Fork Conversation"
>
<div class="flex flex-col gap-4 py-2">
<div class="flex flex-col gap-2">
<Label for="fork-name">Title</Label>
<Input
id="fork-name"
bind:value={forkName}
class="text-foreground"
id="fork-name"
placeholder="Enter fork name"
type="text"
bind:value={forkName}
/>
</div>
<div class="flex items-center gap-2">
<Checkbox
id="fork-attachments"
checked={forkIncludeAttachments}
id="fork-attachments"
onCheckedChange={(checked) => {
forkIncludeAttachments = checked === true;
}}
/>
<Label for="fork-attachments" class="cursor-pointer text-sm font-normal">
<Label class="cursor-pointer text-sm font-normal" for="fork-attachments">
Include all attachments
</Label>
</div>
@@ -30,11 +30,11 @@
role="navigation"
>
<ActionIcon
icon={ChevronLeft}
tooltip="Previous version"
disabled={!hasPrevious}
class="h-5 w-5 p-0 {!hasPrevious ? '!cursor-not-allowed opacity-30' : ''}"
disabled={!hasPrevious}
icon={ChevronLeft}
onclick={() => messageActions.navigateToSibling(previousSiblingId!)}
tooltip="Previous version"
/>
<span class="px-1 font-mono text-xs">
@@ -42,11 +42,11 @@
</span>
<ActionIcon
icon={ChevronRight}
tooltip="Next version"
disabled={!hasNext}
class="h-5 w-5 p-0 {!hasNext ? 'opacity-30' : ''}"
disabled={!hasNext}
icon={ChevronRight}
onclick={() => messageActions.navigateToSibling(nextSiblingId!)}
tooltip="Next version"
/>
</div>
{/if}
@@ -181,26 +181,26 @@
{#snippet renderSection(section: AgenticSection, index: number)}
{#if section.type === AgenticSectionType.TEXT}
<div class="agentic-text">
<MarkdownContent content={section.content} attachments={message?.extra} />
<MarkdownContent attachments={message?.extra} content={section.content} />
</div>
{:else if section.type === AgenticSectionType.REASONING || section.type === AgenticSectionType.REASONING_PENDING}
<ChatMessageReasoningBlock
{section}
open={isExpanded(index, section)}
{isStreaming}
{hasReasoningError}
attachments={message?.extra}
{hasReasoningError}
{isStreaming}
onToggle={() => toggleExpanded(index, section)}
open={isExpanded(index, section)}
{section}
/>
{:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING || section.type === AgenticSectionType.TOOL_CALL_STREAMING}
<ChatMessageToolCallBlock
{section}
open={isExpanded(index, section)}
{isStreaming}
attachments={message?.extra}
isExecuting={section.toolCallId !== undefined &&
section.toolCallId === currentlyExecutingToolCallId}
attachments={message?.extra}
{isStreaming}
onToggle={() => toggleExpanded(index, section)}
open={isExpanded(index, section)}
{section}
/>
{/if}
{/snippet}
@@ -218,15 +218,15 @@
{#if turnStats && showAgenticTurnStats}
<div class="turn-stats transition-opacity duration-150 mt-1 mb-4">
<ChatMessageStatistics
promptTokens={turnStats.llm.prompt_n}
promptMs={turnStats.llm.prompt_ms}
predictedTokens={turnStats.llm.predicted_n}
predictedMs={turnStats.llm.predicted_ms}
agenticTimings={turnStats.toolCalls.length > 0
? buildTurnAgenticTimings(turnStats)
: undefined}
initialView={ChatMessageStatsView.GENERATION}
hideSummary
initialView={ChatMessageStatsView.GENERATION}
predictedMs={turnStats.llm.predicted_ms}
predictedTokens={turnStats.llm.predicted_n}
promptMs={turnStats.llm.prompt_ms}
promptTokens={turnStats.llm.prompt_n}
/>
</div>
{/if}
@@ -240,9 +240,9 @@
{#if pendingPermission && !permissionDismissed}
<ChatMessageActionCardPermissionRequest
toolName={pendingPermission.toolName}
serverLabel={pendingPermission.serverLabel}
onDecision={handlePermission}
serverLabel={pendingPermission.serverLabel}
toolName={pendingPermission.toolName}
/>
{/if}
@@ -102,35 +102,35 @@
<div class="relative w-full max-w-[80%]">
<ChatForm
value={editCtx.editedContent}
attachments={editCtx.editedExtras}
bind:uploadedFiles={editCtx.editedUploadedFiles}
placeholder="Edit your message..."
showMcpPromptButton
showAddButton={editCtx.messageRole === MessageRole.USER}
showModelSelector={editCtx.messageRole === MessageRole.USER}
onValueChange={editCtx.setContent}
attachments={editCtx.editedExtras}
onAttachmentRemove={handleAttachmentRemove}
onUploadedFileRemove={handleUploadedFileRemove}
onFilesAdd={handleFilesAdd}
onSubmit={handleSubmit}
onUploadedFileRemove={handleUploadedFileRemove}
onValueChange={editCtx.setContent}
placeholder="Edit your message..."
showAddButton={editCtx.messageRole === MessageRole.USER}
showMcpPromptButton
showModelSelector={editCtx.messageRole === MessageRole.USER}
value={editCtx.editedContent}
/>
</div>
<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
{#if isUserMessage && editCtx.showSaveOnlyOption}
<div class="flex items-center gap-2">
<Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" />
<Switch bind:checked={saveWithoutRegenerate} class="scale-75" id="save-only-switch" />
<label for="save-only-switch" class="cursor-pointer text-xs text-muted-foreground">
<label class="cursor-pointer text-xs text-muted-foreground" for="save-only-switch">
Update without re-sending
</label>
</div>
{:else if isAssistantMessage}
<div class="flex items-center gap-2">
<Switch id="branch-after-edit" bind:checked={branchAfterEdit} class="scale-75" />
<Switch bind:checked={branchAfterEdit} class="scale-75" id="branch-after-edit" />
<label for="branch-after-edit" class="cursor-pointer text-xs text-muted-foreground">
<label class="cursor-pointer text-xs text-muted-foreground" for="branch-after-edit">
Branch conversation after edit
</label>
</div>
@@ -147,12 +147,12 @@
<DialogConfirmation
bind:open={showDiscardDialog}
title="Discard changes?"
description="You have unsaved changes. Are you sure you want to discard them?"
confirmText="Discard"
cancelText="Keep editing"
variant="destructive"
confirmText="Discard"
description="You have unsaved changes. Are you sure you want to discard them?"
icon={AlertTriangle}
onConfirm={editCtx.cancel}
onCancel={() => (showDiscardDialog = false)}
onConfirm={editCtx.cancel}
title="Discard changes?"
variant="destructive"
/>
@@ -124,23 +124,23 @@
</script>
<CollapsibleContentBlock
{open}
class="my-2"
icon={Lightbulb}
iconClass="h-3.5 w-3.5"
{title}
{subtitle}
{shimmerTitle}
{onToggle}
{open}
{shimmerTitle}
{subtitle}
{title}
>
<div
bind:this={scrollEl}
class="reasoning-content"
class:is-streaming={isPending}
class="reasoning-content"
onscroll={handleScrollEvent}
>
{#if currentConfig.renderThinkingAsMarkdown}
<MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
<MarkdownContent {attachments} class="text-muted-foreground" content={section.content} />
{:else}
<div
class="text-[13px] leading-relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
@@ -140,15 +140,15 @@
{#snippet child({ props })}
<button
{...props}
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
opts.view
? 'bg-background text-foreground shadow-sm'
: opts.disabled
? 'cursor-not-allowed opacity-40'
: 'hover:text-foreground'}"
onclick={() => !opts.disabled && (activeView = opts.view)}
disabled={opts.disabled}
onclick={() => !opts.disabled && (activeView = opts.view)}
type="button"
>
<IconComponent class="h-3 w-3" />
@@ -208,85 +208,85 @@
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={WholeWord}
value="{predictedTokens?.toLocaleString()} tokens"
tooltipLabel="Generated tokens"
value="{predictedTokens?.toLocaleString()} tokens"
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Clock}
value={formattedTime}
tooltipLabel="Generation time"
value={formattedTime}
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Gauge}
value="{tokensPerSecond.toFixed(2)} t/s"
tooltipLabel="Generation speed"
value="{tokensPerSecond.toFixed(2)} t/s"
/>
{:else if activeView === ChatMessageStatsView.TOOLS && hasAgenticStats}
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Wrench}
value="{agenticTimings!.toolCallsCount} calls"
tooltipLabel="Tool calls executed"
value="{agenticTimings!.toolCallsCount} calls"
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Clock}
value={formattedAgenticToolsTime}
tooltipLabel="Tool execution time"
value={formattedAgenticToolsTime}
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Gauge}
value="{agenticToolsPerSecond.toFixed(2)} calls/s"
tooltipLabel="Tool execution rate"
value="{agenticToolsPerSecond.toFixed(2)} calls/s"
/>
{:else if activeView === ChatMessageStatsView.SUMMARY && hasAgenticStats}
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Layers}
value="{agenticTimings!.turns} turns"
tooltipLabel="Agentic turns (LLM calls)"
value="{agenticTimings!.turns} turns"
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={WholeWord}
value="{agenticTimings!.llm.predicted_n.toLocaleString()} tokens"
tooltipLabel="Total tokens generated"
value="{agenticTimings!.llm.predicted_n.toLocaleString()} tokens"
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Clock}
value={formattedAgenticTotalTime}
tooltipLabel="Total time (LLM + tools)"
value={formattedAgenticTotalTime}
/>
{:else if hasPromptStats && (mode === ChatMessageStatisticsMode.READING || isSwitchable)}
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={WholeWord}
value="{promptTokens} tokens"
tooltipLabel="Prompt tokens"
value="{promptTokens} tokens"
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Clock}
value={formattedPromptTime ?? '0s'}
tooltipLabel="Prompt processing time"
value={formattedPromptTime ?? '0s'}
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Gauge}
value="{promptTokensPerSecond!.toFixed(2)} tokens/s"
tooltipLabel="Prompt processing speed"
value="{promptTokensPerSecond!.toFixed(2)} tokens/s"
/>
{/if}
</div>
@@ -32,6 +32,7 @@
</BadgeInfo>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tooltipLabel}</p>
</Tooltip.Content>
@@ -227,14 +227,14 @@
<div>
{#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages } (message.id)}
<ChatMessage
class="mx-auto mt-12 w-full max-w-3xl"
{chatActions}
{message}
{toolMessages}
class="mx-auto mt-12 w-full max-w-3xl"
{isLastAssistantMessage}
{isLastUserMessage}
{message}
{nextAssistantMessage}
{siblingInfo}
{toolMessages}
/>
{/each}
@@ -247,10 +247,10 @@
class="mx-auto mt-12 w-full max-w-[48rem]"
content={pendingContent}
extras={agenticStore.getPendingSteeringMessageExtras(convId)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
onDelete={() => agenticStore.clearSteeringMessage(convId)}
onEdit={(newContent, extras) =>
agenticStore.injectSteeringMessage(convId, newContent, extras)}
onDelete={() => agenticStore.clearSteeringMessage(convId)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
/>
{/if}
{:else if conversationsStore.activeConversation && chatStore.getPendingMessageContent(conversationsStore.activeConversation!.id)}
@@ -262,9 +262,9 @@
class="mx-auto mt-12 w-full max-w-[48rem]"
content={pendingContent}
extras={chatStore.getPendingMessageExtras(convId)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
onEdit={(newContent, extras) => chatStore.injectPendingMessage(convId, newContent, extras)}
onDelete={() => chatStore.clearPendingMessage(convId)}
onEdit={(newContent, extras) => chatStore.injectPendingMessage(convId, newContent, extras)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
/>
{/if}
{/if}
@@ -294,8 +294,8 @@
<ServerLoadingSplash />
{:else}
<div
class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-[calc(100dvh-1rem-var(--chat-tabs-offset,0px))] px-4 md:py-0 pt-12 pb-48 md:pb-4"
style:--chat-form-bottom-position={chatFormBottomPosition}
class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-[calc(100dvh-1rem-var(--chat-tabs-offset,0px))] px-4 md:py-0 pt-12 pb-48 md:pb-4"
ondragenter={dragAndDrop.dragHandlers.dragenter}
ondragleave={dragAndDrop.dragHandlers.dragleave}
ondragover={dragAndDrop.dragHandlers.dragover}
@@ -313,6 +313,7 @@
{/if}
<div
style:padding-top={!isEmpty ? 'var(--chat-form-padding-top)' : undefined}
class={[
'pointer-events-none md:sticky fixed mt-auto transition-all duration-200',
deviceStore.isStandalone
@@ -322,7 +323,6 @@
: 'bottom-2 right-2 left-2',
isEmpty ? 'md:bottom-[calc(50dvh-7rem)] 2xl:bottom-[calc(50dvh-4rem)]' : 'md:bottom-4'
]}
style:padding-top={!isEmpty ? 'var(--chat-form-padding-top)' : undefined}
>
<ChatScreenGreeting {isEmpty} />
@@ -347,6 +347,7 @@
</div>
<ChatScreenForm
bind:uploadedFiles={fileUpload.uploadedFiles}
class="pointer-events-auto conversation-chat-form"
disabled={hasPropsError || chatStore.isEditing()}
{initialMessage}
@@ -356,18 +357,17 @@
onSend={handleSendMessage}
onStop={() => chatStore.stopGeneration()}
onSystemPromptAdd={handleSystemPromptAdd}
bind:uploadedFiles={fileUpload.uploadedFiles}
/>
</div>
</div>
{/if}
<ChatScreenDialogsAndAlerts
{showDeleteDialog}
{handleDeleteConfirm}
{showEmptyFileDialog}
{emptyFileNames}
{activeErrorDialog}
{handleErrorDialogOpenChange}
{emptyFileNames}
{fileUpload}
{handleDeleteConfirm}
{handleErrorDialogOpenChange}
{showDeleteDialog}
{showEmptyFileDialog}
/>
@@ -8,12 +8,12 @@
<div class="pointer-events-auto flex justify-center relative h-0">
<ActionIcon
icon={ArrowDown}
{onclick}
ariaLabel="Scroll to bottom"
tooltip="Scroll to bottom"
size="lg"
iconSize={ICON_CLASS_DEFAULT}
class="h-9 w-9 rounded-full bg-muted/60 border border-border/20 shadow-sm text-accent-foreground absolute bottom-4"
icon={ArrowDown}
iconSize={ICON_CLASS_DEFAULT}
{onclick}
size="lg"
tooltip="Scroll to bottom"
/>
</div>
@@ -26,14 +26,14 @@
<DialogConfirmation
bind:open={showDeleteDialog}
title="Delete Conversation"
description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
confirmText="Delete"
cancelText="Cancel"
variant="destructive"
confirmText="Delete"
description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
icon={Trash2}
onConfirm={handleDeleteConfirm}
onCancel={() => (showDeleteDialog = false)}
onConfirm={handleDeleteConfirm}
title="Delete Conversation"
variant="destructive"
/>
<DialogEmptyFileAlert
@@ -47,8 +47,8 @@
/>
<DialogChatError
message={activeErrorDialog?.message ?? ''}
contextInfo={activeErrorDialog?.contextInfo}
message={activeErrorDialog?.message ?? ''}
onOpenChange={handleErrorDialogOpenChange}
open={Boolean(activeErrorDialog)}
type={activeErrorDialog?.type ?? ErrorDialogType.SERVER}
@@ -147,19 +147,19 @@
});
</script>
<div class="chat-screen-form-wrapper" bind:this={formWrapperEl}>
<div bind:this={formWrapperEl} class="chat-screen-form-wrapper">
<ChatForm
class="mx-auto max-w-3xl {className}"
bind:this={chatFormRef}
bind:value={message}
bind:uploadedFiles
bind:value={message}
class="mx-auto max-w-3xl {className}"
{disabled}
{isLoading}
showMcpPromptButton
onFilesAdd={handleFilesAdd}
{onStop}
onSubmit={handleSubmit}
onSystemPromptClick={handleSystemPromptClick}
onUploadedFileRemove={handleUploadedFileRemove}
showMcpPromptButton
/>
</div>
@@ -22,9 +22,9 @@
{#if !isLoadingModel}
<button
onclick={() => serverStore.fetch()}
disabled={serverStore.loading}
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
disabled={serverStore.loading}
onclick={() => serverStore.fetch()}
>
<RefreshCw class="h-3 w-3 {serverStore.loading ? 'animate-spin' : ''}" />
{serverStore.loading ? 'Retrying...' : 'Retry'}
@@ -8,11 +8,12 @@
{#if state === StreamConnectionState.RESUMING}
<div
aria-live="polite"
class="pointer-events-auto mx-auto mt-2 mb-2 flex max-w-[48rem] items-center gap-2 rounded-md border border-blue-400/40 bg-blue-50/60 px-3 py-1.5 text-sm text-blue-700 dark:bg-blue-950/40 dark:text-blue-200"
role="status"
aria-live="polite"
>
<Loader2 class="h-3.5 w-3.5 animate-spin" />
<span>Reconnecting to the stream...</span>
</div>
{/if}
@@ -81,27 +81,27 @@
</script>
<nav
aria-label="Open conversations"
class="group sticky pl-1 top-0 z-10 hidden md:block chat-tabs-fade transition-[padding] duration-200 ease-in-out pt-3.25 {uiStore.isSidebarExpanded
? CHAT_TABS_MAX_WIDTH.EXPANDED_SIDEBAR
: CHAT_TABS_MAX_WIDTH.COLLAPSED_SIDEBAR}"
aria-label="Open conversations"
>
<div class="relative">
<ScrollCarousel
{carousel}
class="h-10"
containerClass="flex h-10 min-w-0 items-center"
innerClass="items-center gap-1.25"
{carousel}
>
{#each tabs as tab (tab.id)}
<ChatTabsItem
{tab}
isActive={tab.id === activeId}
isLoading={loadingIds.has(tab.id)}
onActivate={(id) => tabsStore.activate(id)}
onAuxClick={handleAuxClick}
onClose={handleClose}
onStop={handleStop}
onAuxClick={handleAuxClick}
{tab}
/>
{/each}
@@ -115,6 +115,7 @@
? 'opacity-100'
: 'opacity-0'}"
></div>
<div
class="pointer-events-none absolute inset-y-0 right-0 z-[5] w-8 bg-gradient-to-l from-background to-transparent transition-opacity {carousel.canScrollRight
? 'opacity-100'
@@ -67,12 +67,12 @@
)}
>
<a
{href}
class="absolute inset-0 z-0 rounded-lg"
onclick={handleActivate}
onauxclick={(e) => onAuxClick?.(tab.id, e)}
aria-current={isActive ? 'page' : undefined}
aria-label={tab.name}
class="absolute inset-0 z-0 rounded-lg"
{href}
onauxclick={(e) => onAuxClick?.(tab.id, e)}
onclick={handleActivate}
></a>
{#if isLoading}
@@ -81,9 +81,9 @@
{#snippet child({ props })}
<button
{...props}
aria-label="Stop generation"
class="stop-button relative z-10 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground"
onclick={(e) => handleActionClick(e, () => onStop?.(tab.id, e))}
aria-label="Stop generation"
>
<Loader2
class="loading-icon {ICON_CLASS_SM} animate-spin transition-opacity duration-300 {contentOpacity}"
@@ -115,12 +115,12 @@
{#snippet child({ props })}
<button
{...props}
aria-label="Close tab"
class={cn(
'relative z-10 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:bg-foreground/10 hover:text-foreground',
contentOpacity
)}
onclick={(e) => handleActionClick(e, () => onClose?.(tab.id))}
aria-label="Close tab"
>
<X class={ICON_CLASS_SM} />
</button>
@@ -15,9 +15,9 @@
{#snippet child({ props })}
<button
{...props}
aria-label="New chat"
class="backdrop-blur-lg flex h-8 w-8 mr-4 shrink-0 cursor-pointer items-center justify-center rounded-md transition-colors hover:bg-foreground/5"
{onclick}
aria-label="New chat"
>
<Plus class="{ICON_CLASS_DEFAULT} opacity-40 transition-opacity group-hover:opacity-100" />
</button>
@@ -40,12 +40,12 @@
</script>
<Collapsible.Root
{open}
class={cn('group/collapsible', 'my-0!', className)}
onOpenChange={(value) => {
open = value;
onToggle?.();
}}
class={cn('group/collapsible', 'my-0!', className)}
{open}
>
<Collapsible.Trigger
class={cn(
@@ -56,10 +56,10 @@
<div class="flex min-w-0 items-start gap-2 text-muted-foreground">
{#if iconUrl}
<img
src={iconUrl}
alt=""
class={cn('shrink-0 rounded-sm mt-0.75', iconClass)}
onerror={hideBrokenIcon}
src={iconUrl}
/>
{:else if IconComponent}
<IconComponent class={cn('shrink-0 text-muted-foreground/60 mt-0.75', iconClass)} />
@@ -40,12 +40,12 @@
</script>
<Collapsible.Root
{open}
class={cn('group/collapsible', 'overflow-hidden rounded-md', className)}
onOpenChange={(value) => {
open = value;
onToggle?.();
}}
class={cn('group/collapsible', 'overflow-hidden rounded-md', className)}
{open}
style="background: var(--code-background); border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
>
<Collapsible.Trigger
@@ -57,10 +57,10 @@
<div class="flex min-w-0 items-start gap-2 text-muted-foreground">
{#if iconUrl}
<img
src={iconUrl}
alt=""
class={cn('shrink-0 rounded-sm mt-0.5', iconClass)}
onerror={hideBrokenIcon}
src={iconUrl}
/>
{:else if IconComponent}
<IconComponent class={cn('shrink-0 text-muted-foreground/60 mt-0.5', iconClass)} />
@@ -867,10 +867,10 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerRef}
onclick={handleMermaidClick}
class="markdown-content {className}{settingsStore.config[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS]
? ' full-height-code-blocks'
: ''}"
onclick={handleMermaidClick}
>
{#each renderedBlocks as block (block.id)}
<div class="markdown-block" {...{ [MARKDOWN_DATA_ATTRS.BLOCK_ID]: block.id }}>
@@ -893,14 +893,16 @@
<div class="mermaid-block-wrapper streaming-mermaid-block">
<div class="code-block-header">
<span class="code-language">mermaid</span>
<div class="code-block-actions">
<ActionIconCopyToClipboard
text={incompleteCodeBlock.code}
canCopy={false}
ariaLabel="Diagram incomplete"
canCopy={false}
text={incompleteCodeBlock.code}
/>
</div>
</div>
<div class="mermaid-loading-placeholder">
<span class="mermaid-loading-text">Generating diagram...</span>
</div>
@@ -909,14 +911,16 @@
<div class="svg-block-wrapper streaming-svg-block">
<div class="code-block-header">
<span class="code-language">svg</span>
<div class="code-block-actions">
<ActionIconCopyToClipboard
text={incompleteCodeBlock.code}
canCopy={false}
ariaLabel="Diagram incomplete"
canCopy={false}
text={incompleteCodeBlock.code}
/>
</div>
</div>
{#if liveSvgHtml}
<div class="svg-scroll-container">
<div class={SVG.BLOCK_CLASS}>
@@ -933,10 +937,11 @@
<div class="code-block-wrapper streaming-code-block relative">
<div class="code-block-header">
<span class="code-language">{incompleteCodeBlock.language || 'text'}</span>
<CodeBlockActions
code={incompleteCodeBlock.code}
language={incompleteCodeBlock.language || 'text'}
disabled
language={incompleteCodeBlock.language || 'text'}
onPreview={(code, lang) => {
previewCode = code;
previewLanguage = lang;
@@ -961,16 +966,16 @@
</div>
<DialogCodePreview
open={previewDialogOpen}
code={previewCode}
language={previewLanguage}
onOpenChange={handlePreviewDialogOpenChange}
open={previewDialogOpen}
/>
<DialogMermaidPreview
onOpenChange={handleMermaidPreviewOpenChange}
open={mermaidPreviewOpen}
svgHtml={mermaidPreviewSvgHtml}
onOpenChange={handleMermaidPreviewOpenChange}
/>
<style>
@@ -103,22 +103,22 @@
<div
class="mermaid-preview-diagram transform-origin-center inline-block min-h-fit min-w-fit will-change-transform {isDragging &&
'select-none'}"
onpointerdown={handlePointerDown}
onpointerleave={handlePointerUp}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
style="transform: translate({translateX}px, {translateY}px) scale({scale}); cursor: {isDragging
? 'grabbing'
: 'grab'};"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointerleave={handlePointerUp}
>
<div bind:this={svgHost}></div>
</div>
<MermaidPreviewControls
{scale}
{svgHtml}
onResetView={resetView}
onZoomIn={zoomIn}
onZoomOut={zoomOut}
onResetView={resetView}
{scale}
{svgHtml}
/>
</div>

Some files were not shown because too many files have changed in this diff Show More