Commit 54cde7106 for llama.cpp
commit 54cde7106a6b4013a053f13bb4a5d91683e7338d
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date: Tue Nov 10 13:33:50 2026 +0100
ui: expand the chat form into a large editor for longer messages (#31973)
* ui: expand the chat form into a large editor for longer messages
The chat form expands into a large editor once a message has markdown
beyond plain paragraphs or reaches four lines, and a Maximize button in its
top-right corner toggles it by hand. The expanded form starts around half the
screen height and grows with the text up to the space below the tabs bar,
animating its height by mirroring the editor's content height.
On a new chat the expanded form keeps the collapsed form's top edge and the
greeting only fades out, waiting for a collapse to settle before it fades
back in. In a conversation the messages follow the form as it grows, so the
latest one stays in view above it.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: make the expanded chat form a full-screen sheet on phones
In the expanded editor Enter adds a line and Cmd/Ctrl+Enter sends.
On a phone the expanded form is a full-screen sheet with no corners or
border. The page around it and Safari's bars take its colour, and the
sidebar's logo button and the working-directory chip step aside while it is
open. It moves by relative offsets and a clip on the main thread, so its
edges, text, actions and chip stay in step in every engine, and its content
keeps the collapsed card's places and widths, so nothing reflows when it
settles. Expanding does not focus the editor on a phone.
On a new chat on desktop the form is lifted by its sticky bottom offset
instead of a translate, and the expanded size is built on the collapsed
card's real height, so its top edge holds still through any sequence of
quick toggles.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: hide the phone menu toggle and the sheet's focus edge while expanded
On a phone the menu toggle rides on the page, above the chat form: it now
fades out while the expanded form covers the screen, and comes back when
it collapses. The expanded sheet also drops the glass rim and inner ring,
so focusing the editor no longer outlines the whole screen.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: keep the expanded phone sheet clear of the status bar
The expanded chat form fills the screen on a phone, under the status bar
an installed app draws over the page. Its text now starts below the bar
(pt-safe) and its collapse button sits below it too (top-safe-2.5).
Assisted-by: Claude (Opus 5.5 Medium)
* ui: hide the phone's context gauge while the chat form is expanded
The gauge pinned to the phone's top right sat over the expanded sheet's
collapse key. Like the menu toggle across from it, it now fades out while
the expanded form covers the screen and comes back when it collapses.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: lift the expanded phone sheet's actions above the iOS keyboard
Assisted-by: Claude (Opus 5.5 Medium)
* ui: move the chat form with a short, quick drift, without sliding after it lands
Its position transition came back on before the browser had laid out the new position,
so it slid the form there after the fade; the drift is now 10px each way and quicker.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: style the expanded chat form with master's surfaces until the design system lands
Assisted-by: Claude (Opus 5.5 Medium)
* ui: tighten the expanding chat form after review
- Expand on markdown only in finished lines: a lone `-`, `#` or `1.` on the
line being typed no longer opens the large editor.
- Show "Cmd/Ctrl+Enter to send" by the send button while expanded.
- Move the expansion state, the phone sheet and the held collapsed height
into useChatFormExpansion, and the sheet's geometry into pure functions.
- Drop the working-directory chip's phone animation, which never ran since
the phone chip moved to the + menu.
- Clearing or sending mid-move cancels the sheet's animation.
- A click only continues at the end of the text when it lands below it.
- Reuse ActionIcon for the toggle and INPUT_CLASSES for the card, derive the
centered form's offset from its drop, name the shared easing and fade.
- The greeting owns its fade behind the expanded form.
- MarkdownEditor's view is reactive, so following its content starts once it
exists.
Assisted-by: Claude (Fable 5.2)
* ui: keep the expanded chat form to the message being written
- The form expands on its own only for what is written or pasted, and
another conversation starts collapsed: a restored draft no longer opens
the large editor, or the phone sheet, on arriving at a chat.
- In the expanded editor the pickers open by the line being written, above
it or, short of room, below, instead of above the form's top edge.
- The sidebar's swipe is off while the phone sheet covers the screen.
- The send hint shows only beside the send button, where the row has room.
- The inner area keeps master's focus shadow, except on the phone sheet.
- The card and the form's position honour reduced motion.
- The phone greeting's place accounts for the wrapper it is counted from.
- The sheet's animation tolerates the form going away mid-move.
Assisted-by: Claude (Fable 5.2)
* ui: turn the typed pickers off on phones, and scroll long messages
- On a phone, typing `/` or `@` opens no picker for now: each mobile
browser moves the page its own way as the keyboard opens, so a list by
the form can not stay clear of the text being written. A note marks it
for later.
- The editor scrolls inside the form again: MarkdownEditor's own box sat
between the input and the editor without taking its height, so a message
longer than the form ran under the actions instead of scrolling.
Assisted-by: Claude (Fable 5.2)
* ui: settle the expanding chat form's review findings
- A phone expands the form only by hand: the sheet switching in mid-typing
moves the text under the keyboard.
- The typed pickers are off wherever a finger is the main pointer, not
below a viewport width, so a narrow desktop window keeps them and an
iPad loses them. The prompt picker opened from the + menu floats by the
sheet's actions on a phone.
- A message put back after a failed send expands the form again.
- Only the left button moves the caret from a click below the text.
- The sheet's fill is one token, used by the sheet, the page and Safari's bars.
- The collapsed-height hold is its own module and no longer finds the
editor's elements by class name.
- Shorter comments.
Assisted-by: Claude (Sonnet 5.5)
* ui: keep a one-line message on the chat form's actions row
On a desktop the main chat form is a single pill while its message is one
line: the add button, the text, then the model selector and send. A second
line, or a line that wraps beside the actions, brings back the two rows,
and they stay until the text is shorter than where it wrapped, so the
wider row can not flip it straight back. The editor stays mounted: the
actions join the text's row by CSS alone.
The single row's corners are `--chat-form-single-row-radius` in app.css, a
pill by default; the "Scrapbook / ChatForm single row" story shows the real
form with other radii.
Assisted-by: Claude (Opus 5.5)
* ui: give the one-line chat form 1.25rem corners
Assisted-by: Claude (Opus 5.5)
* ui: pick the working directory from the + menu on desktop too
The chip under the chat form is gone: the + menu offers the working
directory everywhere but a phone's chat, whose header shows it. The menu
item names the directory once one is set, and the picker gains a row to
clear it, which the chip's X used to do.
Assisted-by: Claude (Opus 5.5)
* ui: put the single-row chat form behind a Display setting
"Single-row chat form for short messages", on by default.
Assisted-by: Claude (Opus 5.5)
* ui: send the chat form on Enter, and add a line with Shift or Ctrl/Cmd
Enter sends wherever the caret is, the expanded editor and code blocks
included; Shift+Enter and Ctrl/Cmd+Enter add a line, continuing lists and
quotes. With "Send message on Enter" off the two swap as before. The
expanded editor's send hint and the code-block rule are gone with it.
Assisted-by: Claude (Opus 5.5)
* ui: start the chat form on the row layout it will settle on
Assisted-by: Claude (Opus 5.5)
* ui: show a set working directory under the chat form again
On desktop the chip under the form is back once a directory is set, and
the + menu offers the directory only until then.
Assisted-by: Claude (Opus 5.5)
* ui: add a line on Enter with an on-screen keyboard
A phone's Return has no Shift, so on a touch screen Enter adds a line and
the send button sends, as "Send message on Enter" turned off does; on a
desktop Enter still sends.
Assisted-by: Claude (Opus 5.5)
diff --git a/tools/ui/src/app.css b/tools/ui/src/app.css
index af6ab16e3..2a56a0862 100644
--- a/tools/ui/src/app.css
+++ b/tools/ui/src/app.css
@@ -104,6 +104,12 @@
--chat-form-area-height: 8rem;
--chat-form-area-offset: 2rem;
--chat-form-padding-top: 6rem;
+ /* the chat form card holding one line of text, as it rests on a new chat */
+ --chat-form-collapsed-height: 6.375rem;
+ /* the chat form's corners while its text and actions share one row */
+ --chat-form-single-row-radius: 1.25rem;
+ /* the card's translucent fill over the page, made opaque: the phone sheet, and the page and Safari's bars around it */
+ --chat-form-sheet-fill: color-mix(in oklab, var(--muted) 60%, var(--background));
--max-message-height: max(24rem, min(80dvh, calc(100dvh - var(--chat-form-area-height) - 12rem)));
}
@@ -131,6 +137,7 @@
}
.dark {
+ --chat-form-sheet-fill: color-mix(in oklab, var(--muted) 75%, var(--background));
--background: oklch(0.16 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
@@ -215,6 +222,16 @@
scrollbar-gutter: stable;
}
+ /* While the chat form is a full-screen sheet on a phone, the page around it takes the
+ sheet's fill: iOS paints the installed app's status bar and home indicator areas, and
+ Safari tints its bars, from this background */
+ @media (width < 48rem) {
+ html[data-chat-form-sheet],
+ html[data-chat-form-sheet] body {
+ background-color: var(--chat-form-sheet-fill);
+ }
+ }
+
/* no elastic bounce past the ends of the page: it drags the sticky and
fixed bars (tab bar, chat form) along and they jump back on release */
html,
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
index dcdcd8f78..800394027 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
@@ -1,6 +1,8 @@
<script lang="ts">
import ContextGaugePopup from './ChatFormContextGauge/ContextGaugePopup.svelte';
+ import { Maximize, Minimize } from '@lucide/svelte';
import {
+ ActionIcon,
ChatAttachmentsList,
ChatFormActions,
ChatFormCurrentWorkingDirectory,
@@ -11,7 +13,9 @@
DialogMcpResourcesBrowser
} from '$lib/components/app';
import {
+ CHAT_FORM_SINGLE_ROW_MAX_TEXT_PX,
CLIPBOARD_CONTENT_QUOTE_PREFIX,
+ ICON_CLASS_DEFAULT,
INITIAL_FILE_SIZE,
INPUT_CLASSES,
PROMPT_CONTENT_SEPARATOR,
@@ -25,6 +29,7 @@
SpecialFileType,
ToolSource
} from '$lib/enums';
+ import { useChatFormExpansion } from '$lib/hooks/use-chat-form-expansion.svelte';
import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
import {
chatStore,
@@ -61,7 +66,8 @@
isAudioRecordingSupported,
microphoneUnavailableReason
} from '$lib/utils/browser-only';
- import { onMount } from 'svelte';
+ import { onMount, untrack } from 'svelte';
+ import type { ClassValue } from 'svelte/elements';
import { toast } from 'svelte-sonner';
interface Props {
@@ -71,8 +77,12 @@
value?: string;
// UI State
- class?: string;
+ class?: ClassValue;
disabled?: boolean;
+ /** Grow into a large editor once the message holds markdown or several lines. */
+ expandable?: boolean;
+ /** On a desktop, keep a message of one line on the actions' row, in a pill. */
+ singleRow?: boolean;
isLoading?: boolean;
placeholder?: string;
showAddButton?: boolean;
@@ -93,6 +103,7 @@
attachments = [],
class: className = '',
disabled = false,
+ expandable = false,
isLoading = false,
onAttachmentRemove,
onFilesAdd,
@@ -105,6 +116,7 @@
placeholder = 'Type a message...',
showAddButton = true,
showModelSelector = true,
+ singleRow = false,
uploadedFiles = $bindable([]),
value = $bindable('')
}: Props = $props();
@@ -126,20 +138,22 @@
/** Why the last start failed (denied, no microphone, busy); the next tap tries again. */
let micError = $state<string | null>(null);
- // Invisible anchor at the form's top edge so the mention/WD popovers
- // float above the box.
- let mentionAnchor: HTMLDivElement | null = $state(null);
+ // Invisible anchor the pickers' popovers float above: the form's top edge, or in the
+ // expanded editor the line being written
+ let pickerAnchor: HTMLDivElement | null = $state(null);
+ let pickerAnchorBox = $state({ height: 1, top: 0 });
let cwd = $derived(
conversationsStore.activeConversation?.cwd ?? conversationsStore.preferences.pendingCwd
);
let showWorkingDirectory = $derived(conversationsStore.preferences.hasEnabledCwdTools());
- // A phone has no chip for the working directory under the form: a chat shows it in its
- // header (see ChatScreenMobileHeader), and a new chat, which has no header yet, offers it in
- // the + menu
+ // On a desktop the chip under the form shows a directory once one is set, and the + menu
+ // sets one until then. A phone has no chip: a chat shows it in its header (see
+ // ChatScreenMobileHeader), and a new chat, which has no header yet, in the + menu
+ let showWorkingDirectoryChip = $derived(showWorkingDirectory && !deviceStore.isMobile && !!cwd);
let isWorkingDirectoryInAddMenu = $derived(
- showWorkingDirectory && deviceStore.isMobile && !conversationsStore.activeConversation
+ showWorkingDirectory && (deviceStore.isMobile ? !conversationsStore.activeConversation : !cwd)
);
const pickers = useChatFormPickers({
@@ -165,6 +179,10 @@
},
onCompact: () => chatStore.compactConversation(),
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
+ // TODO: no typed pickers with an on-screen keyboard for now: each mobile browser moves
+ // the page its own way as it opens, so a list by the form can not stay clear of the
+ // text. A drawer with its own search field (as the directory picker is) could.
+ opensOnInput: () => !deviceStore.isTouch,
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
setValue: (v) => {
value = v;
@@ -218,12 +236,108 @@
);
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
+ const EXPANDED_SHEET_FILL = 'max-md:bg-(--chat-form-sheet-fill)';
+
+ let cardEl: HTMLDivElement | undefined = $state();
+ let actionsEl: HTMLDivElement | undefined = $state();
+
+ const expansion = useChatFormExpansion({
+ // on a phone this leaves focus where it is: focusing would open the keyboard
+ focusInput: () => inputRef?.focus(),
+ getActions: () => actionsEl,
+ getCard: () => cardEl,
+ getContent: () => inputRef?.getContentDOM(),
+ getEditorBox: () => inputRef?.getBox(),
+ // the conversation the message is for: its draft arrives without a remount
+ getResetKey: () => conversationsStore.activeConversation?.id,
+ getValue: () => value,
+ isEnabled: () => expandable
+ });
+ let isExpanded = $derived(expansion.isExpanded);
+
+ // One line of text shares the actions' row; a second line, or one that wraps beside the
+ // actions, takes the two rows. It stays there until the text is shorter than where it
+ // wrapped, as the wider row of its own would fit it again and flip it straight back
+ let textHeight = $state<number | null>(null);
+ let wrappedAtLength = Infinity;
+ let canBeSingleRow = $derived(
+ singleRow &&
+ !deviceStore.isMobile &&
+ !isExpanded &&
+ !hasAttachments &&
+ !mcpStore.resources.hasAttachments &&
+ !value.includes('\n')
+ );
+ // starts as it will settle, so a phone never draws a frame of the single row
+ let isSingleRow = $state(untrack(() => canBeSingleRow));
+
+ // Re-runs after its own switch too, and measures the text as it is laid out then: the
+ // height the input last reported can be the other layout's
+ $effect(() => {
+ void textHeight;
+
+ const height = inputRef?.getContentDOM()?.offsetHeight ?? 0;
+ const fitsOneLine = height < CHAT_FORM_SINGLE_ROW_MAX_TEXT_PX;
+ const length = value.length;
+
+ if (!length) wrappedAtLength = Infinity;
+
+ if (!canBeSingleRow) {
+ isSingleRow = false;
+ } else if (isSingleRow && !fitsOneLine) {
+ isSingleRow = false;
+ wrappedAtLength = length;
+ } else if (!isSingleRow && length < wrappedAtLength) {
+ isSingleRow = true;
+ }
+ });
+ let isPickerOpen = $derived(
+ pickers.isCommandPickerOpen ||
+ pickers.isMentionPickerOpen ||
+ pickers.isPromptPickerOpen ||
+ pickers.isWorkingDirectoryPickerOpen
+ );
+
+ // On a phone the sheet's pickers float by its actions, which ride above the keyboard
+ let activePickerAnchor = $derived(
+ isExpanded && deviceStore.isMobile ? (actionsEl ?? null) : pickerAnchor
+ );
+
+ // before the popover is drawn, and again as the query is typed and the line moves
+ $effect.pre(() => {
+ if (!isPickerOpen) return;
+
+ void value;
+
+ const line = isExpanded && !deviceStore.isMobile ? inputRef?.getCaretRect() : null;
+ const formTop = pickerAnchor?.parentElement?.getBoundingClientRect().top ?? 0;
+
+ pickerAnchorBox = line
+ ? { height: line.bottom - line.top, top: line.top - formTop }
+ : { height: 1, top: 0 };
+ });
+ // shown on hover (always on touch screens) while collapsed, always while expanded: on
+ // the phone sheet it keeps the collapsed card's place, below the status bar
+ let expandToggleClass = $derived(
+ [
+ 'absolute top-2.5 right-2.5 z-10 h-8 w-8 rounded-full text-muted-foreground transition-opacity duration-150',
+ isExpanded
+ ? 'max-md:top-safe-2.5 max-md:right-[calc(0.625rem+var(--chat-form-side-inset,0.5rem))]'
+ : 'opacity-0 group-hover/chat-form:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100'
+ ].join(' ')
+ );
+
onMount(() => {
recordingSupported = isAudioRecordingSupported();
micUnavailableReason = microphoneUnavailableReason();
audioRecorder = new AudioRecorder();
});
+ /** Expands for the message as it stands, e.g. one put back after a send failed. */
+ export function expandForMessage() {
+ expansion.handleInput();
+ }
+
export function focus() {
inputRef?.focus();
}
@@ -277,23 +391,18 @@
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
const isModifier = event.ctrlKey || event.metaKey;
- const sendOnEnter = currentConfig.sendOnEnter !== false;
+ // Enter sends and Ctrl/Cmd+Enter adds a line, the other way round when sending
+ // on Enter is off or the keyboard is on screen, whose Return has no Shift;
+ // Shift+Enter always adds one. Skipping submit lets the editor's newline through
+ const sendOnEnter = currentConfig.sendOnEnter !== false && !deviceStore.isTouch;
- // Caret inside a code block (``` or ~~~ fenced, closed or
- // still open while being typed, or indented): Enter adds a
- // line, never submits - skipping submit lets the editor's own
- // newline through.
- if (!isModifier && inputRef?.isCaretInCodeBlock()) {
- return;
- }
+ if (isModifier === sendOnEnter) return;
- if (sendOnEnter || isModifier) {
- event.preventDefault();
+ event.preventDefault();
- if (!canSubmit || disabled || hasLoadingAttachments) return;
+ if (!canSubmit || disabled || hasLoadingAttachments) return;
- onSubmit?.();
- }
+ onSubmit?.();
}
}
@@ -519,7 +628,15 @@
<ChatFormInputFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
<form
- class="relative grid {className}"
+ class={[
+ // one flex column in both states, so expanding changes only sizes, which ease
+ 'relative flex flex-col',
+ // expanded, the form never outgrows the room under the tabs (the whole screen on a
+ // phone): the page never scrolls, the editor does
+ isExpanded &&
+ 'max-h-[calc(100dvh-var(--chat-tabs-offset,0px)-var(--chat-form-padding-top)-2rem)] max-md:max-h-dvh',
+ className
+ ]}
onsubmit={(event) => {
event.preventDefault();
@@ -528,59 +645,117 @@
onSubmit?.();
}}
>
- <ChatFormPickers
- bind:this={pickersRef}
- 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}
- onMentionOpened={() => inputRef?.focus()}
- onMentionPickerClose={pickers.handleMentionPickerClose}
- onMentionSelect={handleMentionSelect}
- onPromptLoadComplete={handlePromptLoadComplete}
- onPromptLoadError={handlePromptLoadError}
- onPromptLoadStart={handlePromptLoadStart}
- onPromptPickerClose={pickers.handlePromptPickerClose}
- promptSearchQuery={pickers.promptSearchQuery}
- scopePath={pickers.mentionScopePath}
- />
-
+ <!-- the pickers' own triggers fill this box, so all of them share the anchor -->
<div
- bind:this={mentionAnchor}
- aria-hidden="true"
- class="pointer-events-none absolute top-0 right-0 left-0 h-px"
- ></div>
+ bind:this={pickerAnchor}
+ style:height="{pickerAnchorBox.height}px"
+ style:top="{pickerAnchorBox.top}px"
+ class="pointer-events-none absolute right-0 left-0"
+ >
+ <ChatFormPickers
+ bind:this={pickersRef}
+ anchor={activePickerAnchor}
+ commandQuery={pickers.commandQuery}
+ commands={pickers.availableCommands}
+ isCommandPickerOpen={pickers.isCommandPickerOpen}
+ isMentionPickerOpen={pickers.isMentionPickerOpen}
+ isPromptPickerOpen={pickers.isPromptPickerOpen}
+ mentionQuery={pickers.mentionQuery}
+ onCommandPickerClose={pickers.handleCommandPickerClose}
+ onCommandSelect={pickers.handleCommandSelect}
+ onMentionOpened={() => inputRef?.focus()}
+ onMentionPickerClose={pickers.handleMentionPickerClose}
+ onMentionSelect={handleMentionSelect}
+ onPromptLoadComplete={handlePromptLoadComplete}
+ onPromptLoadError={handlePromptLoadError}
+ onPromptLoadStart={handlePromptLoadStart}
+ onPromptPickerClose={pickers.handlePromptPickerClose}
+ promptSearchQuery={pickers.promptSearchQuery}
+ scopePath={pickers.mentionScopePath}
+ />
+ </div>
<div
- class="{INPUT_CLASSES} overflow-hidden rounded-4xl md:rounded-3xl backdrop-blur-md {disabled
- ? 'cursor-not-allowed opacity-60'
- : ''}"
+ bind:this={cardEl}
+ class={[
+ INPUT_CLASSES,
+ 'backdrop-blur-md group/chat-form relative flex shrink flex-col overflow-hidden transition-[min-height,padding,border-radius,border-color,background-color] duration-400 ease-[var(--chat-form-resize-ease,cubic-bezier(0.3,1.12,0.5,1))] motion-reduce:transition-none',
+ isSingleRow ? 'rounded-(--chat-form-single-row-radius)' : 'rounded-4xl md:rounded-3xl',
+ // on a phone the sheet's animation moves it (see useChatFormExpansion): only its
+ // border, hidden on the sheet, fades back in after
+ 'max-md:transition-[border-color] max-md:duration-200',
+ // Expanded, it opens to about half the screen, or on a new chat (where the page
+ // sets how far the form drops) to its collapsed height plus that drop, so its top
+ // edge stays put. The collapsed height is held at the card's real one while it moves
+ '[--chat-form-expanded-min-height:calc(var(--chat-form-drop)+var(--chat-form-collapsed-height))]',
+ isExpanded
+ ? 'min-h-[min(var(--chat-form-expanded-min-height,50dvh),calc(100dvh-var(--chat-tabs-offset,0px)-var(--chat-form-padding-top)-5rem))]'
+ : ['min-h-0', expandable && !isSingleRow && 'md:min-h-(--chat-form-collapsed-height)'],
+ // the phone sheet's fill is opaque, so Safari's toolbar strip can match it and it
+ // needs no backdrop blur, the costliest thing to repaint
+ (isExpanded || expansion.isBlurHeld) && 'max-md:backdrop-blur-none',
+ isExpanded && [
+ // its actions keep the collapsed form's gap from the screen's bottom, or ride
+ // 0.5rem above the keyboard (unset, the second term is 0)
+ 'max-md:min-h-dvh max-md:rounded-none max-md:pb-[max(var(--chat-form-bottom-position,0px),calc(var(--keyboard-inset,-0.5rem)+0.5rem))]',
+ // clear of the status bar, which an installed app draws over the page
+ 'max-md:pt-safe',
+ // no rim, ring or visible border along the screen's edges; the border stays
+ // transparent so nothing inside shifts by its width
+ 'max-md:[--glass-edge-side:transparent] max-md:[--glass-edge-mid:transparent] max-md:[--glass-edge-side-focus:transparent] max-md:[--glass-edge-mid-focus:transparent] max-md:[--glass-highlight:transparent]',
+ 'max-md:border-transparent max-md:focus-within:border-transparent max-md:dark:border-transparent max-md:dark:focus-within:border-transparent',
+ EXPANDED_SHEET_FILL
+ ],
+ disabled && 'cursor-not-allowed opacity-60'
+ ]}
+ data-single-row={isSingleRow || undefined}
data-slot="input-area"
>
+ <!-- a single row has no room for it: a message expands once it has a second line -->
+ {#if expandable && !isSingleRow}
+ <ActionIcon
+ class={expandToggleClass}
+ icon={isExpanded ? Minimize : Maximize}
+ iconSize={ICON_CLASS_DEFAULT}
+ onclick={expansion.toggle}
+ tooltip={isExpanded ? 'Collapse editor' : 'Expand editor'}
+ />
+ {/if}
+
<ChatAttachmentsList
bind:uploadedFiles
activeModelId={activeModelId ?? undefined}
{attachments}
- class="py-5"
+ class={isExpanded ? 'py-5 max-md:px-(--chat-form-side-inset)' : '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!"
+ class={[
+ 'relative flex flex-1 shadow-sm transition-shadow focus-within:shadow-md',
+ // the add button, the text and the rest of the actions, or the text above them
+ isSingleRow
+ ? 'items-center gap-2 rounded-(--chat-form-single-row-radius) px-3 py-2.5'
+ : 'min-h-12 flex-col rounded-4xl py-2 pb-2.25 md:rounded-3xl md:py-3!',
+ // the phone sheet is wider than the collapsed card by the page's side inset: its
+ // content is inset by as much, so nothing reflows while it moves
+ isExpanded && 'max-md:px-(--chat-form-side-inset)',
+ // the sheet meets the screen's edges: no corners, and no shadow along them
+ isExpanded && 'max-md:rounded-none max-md:shadow-none max-md:focus-within:shadow-none'
+ ]}
>
<ChatFormInput
+ bind:contentHeight={textHeight}
bind:this={inputRef}
bind:value
- class="px-5 py-1.5 md:pt-0"
+ class={isSingleRow ? 'pr-1' : ['px-5 py-1.5 md:pt-0', expandable && 'pr-12']}
+ compact={isSingleRow}
{disabled}
+ expanded={isExpanded}
onInput={() => {
+ expansion.handleInput();
pickers.handleInput();
onValueChange?.(value);
}}
@@ -599,43 +774,62 @@
/>
{/if}
- <ChatFormActions
- bind:this={chatFormActionsRef}
- canSend={canSubmit}
- class="px-3"
- {disabled}
- {isLoading}
- isReasoning={chatStore.isReasoning}
- {isRecording}
- micWarning={micUnavailableReason ?? micError}
- onFileUpload={handleFileUpload}
- onMicClick={handleMicClick}
- {onStop}
- onSystemPromptClick={() => onSystemPromptClick?.({ files: uploadedFiles, message: value })}
- onWorkingDirectoryClick={isWorkingDirectoryInAddMenu
- ? pickers.handleWorkingDirectoryOpen
- : undefined}
- {showAddButton}
- {showModelSelector}
- {uploadedFiles}
- />
+ <!-- its own box, so the phone sheet can hold it by the bottom edge while it moves -->
+ <div bind:this={actionsEl} class={isSingleRow ? 'contents' : 'relative mt-auto'}>
+ <ChatFormActions
+ bind:this={chatFormActionsRef}
+ canSend={canSubmit}
+ class={isSingleRow ? undefined : 'px-3'}
+ {disabled}
+ inline={isSingleRow}
+ {isLoading}
+ isReasoning={chatStore.isReasoning}
+ {isRecording}
+ micWarning={micUnavailableReason ?? micError}
+ onFileUpload={handleFileUpload}
+ onMicClick={handleMicClick}
+ {onStop}
+ onSystemPromptClick={() =>
+ onSystemPromptClick?.({ files: uploadedFiles, message: value })}
+ onWorkingDirectoryClick={isWorkingDirectoryInAddMenu
+ ? pickers.handleWorkingDirectoryOpen
+ : undefined}
+ {showAddButton}
+ {showModelSelector}
+ {uploadedFiles}
+ workingDirectory={cwd}
+ />
+ </div>
</div>
</div>
<ContextGaugePopup />
+ <!-- iOS Safari tints its bottom toolbar from a solid fixed element at the screen's bottom
+ edge: this strip carries the sheet's fill while it rests -->
+ {#if isExpanded && !expansion.isSheetMoving && deviceStore.isIOSSafari}
+ <div
+ aria-hidden="true"
+ class={[
+ 'pointer-events-none fixed inset-x-0 bottom-0 -z-10 h-3 md:hidden',
+ EXPANDED_SHEET_FILL
+ ]}
+ ></div>
+ {/if}
+
{#if showWorkingDirectory}
- <!-- a phone opens the picker from elsewhere (see isWorkingDirectoryInAddMenu): no chip here -->
+ <!-- opened from the chip, the + menu, a phone chat's header or `/cwd` -->
<ChatFormCurrentWorkingDirectory
bind:query={pickers.workingDirectoryQuery}
- customAnchor={mentionAnchor}
+ class="self-start"
+ customAnchor={activePickerAnchor}
directory={cwd}
{disabled}
isOpen={pickers.isWorkingDirectoryPickerOpen}
onChange={handleWorkingDirectoryChange}
onClose={pickers.handleWorkingDirectoryClose}
onOpen={pickers.handleWorkingDirectoryOpen}
- showChip={!deviceStore.isMobile}
+ showChip={showWorkingDirectoryChip}
/>
{/if}
</form>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDrawer.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDrawer.svelte
index addf56009..a3d3d1a19 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDrawer.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDrawer.svelte
@@ -18,8 +18,7 @@
ATTACHMENT_FILE_ITEMS,
ATTACHMENT_MENU_TEXT,
ATTACHMENT_MODALITY_TEXT,
- ICON_CLASS_DEFAULT,
- SET_WORKING_DIRECTORY_LABEL
+ ICON_CLASS_DEFAULT
} from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts';
import { AttachmentAction, AttachmentItemEnabledWhen } from '$lib/enums/attachment.enums';
@@ -148,7 +147,7 @@
>
<Folder class="{ICON_CLASS_DEFAULT} shrink-0" />
- <span>{SET_WORKING_DIRECTORY_LABEL}</span>
+ <span class="truncate">{chatFormActions.workingDirectoryLabel}</span>
</button>
{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte
index 2cae6e8ca..f746a73cb 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
- import { File, Image, MessageSquare, Mic, Plus, Video } from '@lucide/svelte';
+ import { File, Folder, Image, MessageSquare, Mic, Plus, Video } from '@lucide/svelte';
import { ChatFormActionAddToolsSubmenu } from '$lib/components/app';
import { buttonVariants } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
@@ -143,6 +143,21 @@
<span>{ATTACHMENT_MENU_TEXT.SYSTEM_MESSAGE}</span>
</DropdownMenu.Item>
+ {#if chatFormActions.onWorkingDirectoryClick}
+ <DropdownMenu.Item
+ class="flex cursor-pointer items-center gap-2"
+ onclick={() => {
+ // the picker takes the focus, not the menu's trigger
+ suppressCloseAutoFocus = true;
+ chatFormActions.onWorkingDirectoryClick?.();
+ }}
+ >
+ <Folder class={ICON_CLASS_DEFAULT} />
+
+ <span class="truncate">{chatFormActions.workingDirectoryLabel}</span>
+ </DropdownMenu.Item>
+ {/if}
+
<ChatFormActionAddToolsSubmenu />
</DropdownMenu.Content>
</DropdownMenu.Root>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
index e27485a04..23b63aa58 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
@@ -9,19 +9,21 @@
ModelsSelector
} from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
- import { ICON_CLASS_DEFAULT } from '$lib/constants';
+ import { ICON_CLASS_DEFAULT, SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
import { setChatFormActionsContext } from '$lib/contexts';
import { FileTypeCategory } from '$lib/enums';
import { useChatFormModel } from '$lib/hooks/use-chat-form-model.svelte';
import { useShowContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import { ChatService } from '$lib/services';
- import { conversationsStore, deviceStore, settingsStore } from '$lib/stores';
- import { getFileTypeCategory } from '$lib/utils';
+ import { conversationsStore, deviceStore, settingsStore, toolsStore } from '$lib/stores';
+ import { abbreviateWorkingDir, getFileTypeCategory } from '$lib/utils';
interface Props {
canSend?: boolean;
class?: string;
disabled?: boolean;
+ /** Lay the actions out in the row around them, the add button first, the rest last. */
+ inline?: boolean;
isLoading?: boolean;
isReasoning?: boolean;
isRecording?: boolean;
@@ -30,6 +32,8 @@
showAddButton?: boolean;
showModelSelector?: boolean;
uploadedFiles?: ChatUploadedFile[];
+ /** The chat's working directory, which the + menu names. */
+ workingDirectory?: string | null;
onFileUpload?: () => void;
onMicClick?: () => void;
onStop?: () => void;
@@ -41,6 +45,7 @@
canSend = false,
class: className = '',
disabled = false,
+ inline = false,
isLoading = false,
isReasoning = false,
isRecording = false,
@@ -52,7 +57,8 @@
onWorkingDirectoryClick,
showAddButton = true,
showModelSelector = true,
- uploadedFiles = []
+ uploadedFiles = [],
+ workingDirectory = null
}: Props = $props();
let currentConfig = $derived(settingsStore.config);
@@ -113,16 +119,25 @@
},
get onWorkingDirectoryClick() {
return onWorkingDirectoryClick;
+ },
+ get workingDirectoryLabel() {
+ return workingDirectory
+ ? abbreviateWorkingDir(workingDirectory, toolsStore.serverHome)
+ : SET_WORKING_DIRECTORY_LABEL;
}
});
</script>
<div
- class="flex w-full items-center gap-3 {className} {showAddButton ? '' : 'justify-end'}"
- style="container-type: inline-size"
+ style:container-type={inline ? undefined : 'inline-size'}
+ class={[
+ inline ? 'contents' : 'flex w-full items-center gap-3',
+ className,
+ !showAddButton && 'justify-end'
+ ]}
>
{#if showAddButton}
- <div class="mr-auto flex items-center gap-2">
+ <div class={['flex items-center gap-2', inline ? 'order-first' : 'mr-auto']}>
<ChatFormActionsAdd />
</div>
{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte
index 146b3f736..59020eec2 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
import ChatFormCurrentWorkingDirectoryChip from './ChatFormCurrentWorkingDirectoryChip.svelte';
import ChatFormCurrentWorkingDirectoryResultsList from './ChatFormCurrentWorkingDirectoryResultsList.svelte';
- import { FolderOpen } from '@lucide/svelte';
+ import { FolderOpen, X } from '@lucide/svelte';
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import * as Drawer from '$lib/components/ui/drawer';
import * as Popover from '$lib/components/ui/popover';
@@ -21,7 +21,7 @@
class?: string;
disabled?: boolean;
directory?: string | null;
- /** Controlled open state; the host owns it so the chip click and the
+ /** Controlled open state; the host owns it so the chip, the + menu and the
* `/cwd` slash command open the picker through the same path. */
isOpen: boolean;
/** Two-way bound query, kept in sync with the text after `/cwd `. */
@@ -365,6 +365,23 @@
</button>
{/if}
+ {#if directory}
+ <button
+ class={[
+ 'flex cursor-pointer items-center outline-hidden select-none hover:bg-accent hover:text-accent-foreground',
+ inDrawer
+ ? 'gap-2 rounded-lg px-2 py-2.5 text-[15px]'
+ : 'gap-2 rounded-sm px-2 py-1.5 text-sm'
+ ]}
+ onclick={clearDirectory}
+ type="button"
+ >
+ <X class="size-4 shrink-0 text-muted-foreground" />
+
+ <span>Clear working directory</span>
+ </button>
+ {/if}
+
{#if homeBase && fileSearchEnabled}
<div
aria-hidden="true"
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte
index 35d4bca07..93b5bd421 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte
@@ -7,10 +7,17 @@
import { settingsStore } from '$lib/stores/settings/index.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import { onMount } from 'svelte';
+ import type { ClassValue } from 'svelte/elements';
interface Props {
- class?: string;
+ class?: ClassValue;
+ /** One line tall, beside the actions instead of above them. */
+ compact?: boolean;
+ /** The height of the text, which the form reads to know when it fills a line. */
+ contentHeight?: number | null;
disabled?: boolean;
+ /** Fill the expanded chat form: the editor takes all its height and scrolls inside. */
+ expanded?: boolean;
onInput?: () => void;
onKeydown?: (event: KeyboardEvent) => void;
onPaste?: (event: ClipboardEvent) => void;
@@ -20,7 +27,10 @@
let {
class: className = '',
+ compact = false,
+ contentHeight = $bindable(null),
disabled = false,
+ expanded = false,
onInput,
onKeydown,
onPaste,
@@ -29,6 +39,7 @@
}: Props = $props();
let editor: MarkdownEditor | undefined = $state();
+ let box: HTMLDivElement | undefined = $state();
let livePreview = $derived(settingsStore.config[SETTINGS_KEYS.LIVE_MARKDOWN_PREVIEW] !== false);
@@ -37,6 +48,20 @@
`${settingsStore.config[SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS]}|${toolsStore.serverHome ?? ''}`
);
+ // CSS can not transition a height that follows its content, so the editor's content
+ // height is mirrored onto this wrapper as a length the stylesheet transitions
+ $effect(() => {
+ const content = editor?.getContentDOM();
+
+ if (!content) return;
+
+ const observer = new ResizeObserver(() => (contentHeight = content.offsetHeight));
+
+ observer.observe(content);
+
+ return () => observer.disconnect();
+ });
+
// The form gets the keys and pastes first: whatever it handles (submit, pickers,
// pasted files) never reaches the editor
const extensions = [
@@ -59,6 +84,24 @@
onInput?.();
}
+ // The expanded editor is taller than its text: a click on the empty room below it
+ // continues writing at the end, as in a textarea
+ function handleMouseDown(event: MouseEvent) {
+ const content = editor?.getContentDOM();
+
+ if (
+ event.button !== 0 ||
+ !expanded ||
+ !content ||
+ event.clientY <= content.getBoundingClientRect().bottom
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ editor?.focusAt();
+ }
+
onMount(() => {
if (!deviceStore.isMobile) editor?.focus();
});
@@ -69,6 +112,19 @@
editor?.focus();
}
+ /** The element that sets the editor's height. */
+ export function getBox() {
+ return box;
+ }
+
+ export function getContentDOM() {
+ return editor?.getContentDOM();
+ }
+
+ export function getCaretRect() {
+ return editor?.getCaretRect() ?? null;
+ }
+
/** Cursor offset in the markdown source. */
export function getCaretOffset(): number {
return editor?.getCaretOffset() ?? 0;
@@ -78,11 +134,6 @@
editor?.focusAt(offset);
}
- /** Is the cursor in a code block, where Enter adds a line? */
- export function isCaretInCodeBlock(): boolean {
- return editor?.isCaretInCodeBlock() ?? false;
- }
-
/** Replace the text as one undoable edit with the cursor at `caret` (e.g. a picked mention). */
export function replaceText(next: string, caret: number) {
if (editor) {
@@ -94,14 +145,21 @@
</script>
<div
+ bind:this={box}
+ style:height={contentHeight === null ? undefined : `${contentHeight}px`}
class={[
- 'chat-form-input mb-0.5 min-h-[2.375rem] flex-1',
+ 'chat-form-input',
+ compact ? 'compact' : 'mb-0.5',
+ expanded && 'expanded',
disabled && 'cursor-not-allowed',
className
]}
+ onmousedown={handleMouseDown}
+ role="presentation"
>
<MarkdownEditor
bind:this={editor}
+ class="chat-form-editor"
{disabled}
editingWidgets={fileMentionWidgets}
{extensions}
@@ -114,16 +172,58 @@
</div>
<style>
- /* Plain text reads exactly like the former textarea */
+ /* Plain text reads exactly like the former textarea. The height (set from the
+ content) excludes the padding, which the form sets around it; expanded, it grows
+ into the room the form gives the editor */
.chat-form-input {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ box-sizing: content-box;
+ min-height: 2.25rem;
+ max-height: var(--max-message-height);
line-height: 1.5rem;
+ transition: height 400ms cubic-bezier(0.3, 1.12, 0.5, 1);
+ }
+
+ .chat-form-input.expanded {
+ max-height: none;
+ }
+
+ .chat-form-input.compact {
+ min-height: 1.5rem;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .chat-form-input {
+ transition: none;
+ }
+ }
+
+ /* Every box down to the editor takes the wrapper's height, so the editor scrolls inside it */
+ .chat-form-input :global(:is(.chat-form-editor, .markdown-content)) {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-height: 0;
+ }
+
+ .chat-form-input :global(.cm-editor) {
+ flex: 1;
+ min-height: 0;
}
.chat-form-input :global(.cm-scroller) {
- max-height: var(--max-message-height);
overflow-y: auto;
}
+ /* The content keeps the height of its text: CodeMirror stretches it over the whole
+ editor by default, which would feed the editor's own height back as the content
+ height and hold the form at its expanded size */
+ .chat-form-input :global(.cm-content) {
+ min-height: 0;
+ }
+
/* Rendered paragraphs and lists keep the line height of the text being typed */
.chat-form-input :global(.cm-md-block :is(p, li)) {
line-height: inherit;
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte
index 2d91ceb54..f2ac4aba2 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte
@@ -4,6 +4,8 @@
interface Props {
class?: string;
+ /** What the popover floats by, instead of the box its trigger fills. */
+ customAnchor?: HTMLElement | null;
isOpen?: boolean;
srLabel?: string;
onClose?: () => void;
@@ -14,6 +16,7 @@
let {
children,
class: className = '',
+ customAnchor = null,
isOpen = $bindable(false),
onClose,
onKeydown,
@@ -40,6 +43,7 @@
<Popover.Content
align="start"
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
+ {customAnchor}
onOpenAutoFocus={(event) => event.preventDefault()}
onkeydown={onKeydown}
preventScroll={false}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte
index e3ff9d780..6aa3c5e45 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte
@@ -15,6 +15,7 @@
*/
interface Props {
class?: string;
+ customAnchor?: HTMLElement | null;
isOpen: boolean;
query: string;
commands: ChatFormCommand[];
@@ -22,7 +23,15 @@
onSelect: (command: ChatFormCommand) => void;
}
- let { class: className = '', commands, isOpen, onClose, onSelect, query }: Props = $props();
+ let {
+ class: className = '',
+ commands,
+ customAnchor = null,
+ isOpen,
+ onClose,
+ onSelect,
+ query
+ }: Props = $props();
const trimmedQuery = $derived((query ?? '').trim().toLowerCase());
@@ -96,6 +105,7 @@
<ChatFormPickerPopover
bind:isOpen
class={className}
+ {customAnchor}
{onClose}
onKeydown={handleKeydown}
srLabel="Open command picker"
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte
index f6a3ee134..eadcdbcbb 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte
@@ -16,6 +16,7 @@
interface Props {
class?: string;
+ customAnchor?: HTMLElement | null;
isOpen?: boolean;
searchQuery?: string;
onClose?: () => void;
@@ -30,6 +31,7 @@
let {
class: className = '',
+ customAnchor = null,
isOpen = false,
onClose,
onPromptLoadComplete,
@@ -358,6 +360,7 @@
<ChatFormPickerPopover
bind:isOpen
class={className}
+ {customAnchor}
{onClose}
onKeydown={handleKeydown}
srLabel="Open prompt picker"
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte
index b9bc4cda5..7f8ba3e6d 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte
@@ -10,6 +10,8 @@
} from '$lib/types';
interface Props {
+ /** What every picker's popover floats by. */
+ anchor?: HTMLElement | null;
isCommandPickerOpen?: boolean;
commandQuery?: string;
commands?: ChatFormCommand[];
@@ -17,7 +19,6 @@
promptSearchQuery?: string;
isMentionPickerOpen?: boolean;
mentionQuery?: string;
- mentionAnchor?: HTMLElement | null;
scopePath?: string | null;
onCommandPickerClose?: () => void;
onCommandSelect?: (command: ChatFormCommand) => void;
@@ -35,12 +36,12 @@
}
let {
+ anchor,
commandQuery,
commands = [],
isCommandPickerOpen,
isMentionPickerOpen,
isPromptPickerOpen,
- mentionAnchor,
mentionQuery,
onCommandPickerClose,
onCommandSelect,
@@ -80,6 +81,7 @@
<ChatFormPickerCommand
bind:this={commandPickerRef}
{commands}
+ customAnchor={anchor}
isOpen={isCommandPickerOpen ?? false}
onClose={onCommandPickerClose ?? (() => {})}
onSelect={onCommandSelect ?? (() => {})}
@@ -88,6 +90,7 @@
<ChatFormPickerMcpPrompts
bind:this={promptPickerRef}
+ customAnchor={anchor}
isOpen={isPromptPickerOpen}
onClose={onPromptPickerClose}
{onPromptLoadComplete}
@@ -98,7 +101,7 @@
<ChatFormPickerMention
bind:this={mentionPickerRef}
- customAnchor={mentionAnchor}
+ customAnchor={anchor}
isOpen={isMentionPickerOpen ?? false}
onClose={onMentionPickerClose ?? (() => {})}
onOpened={onMentionOpened}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/chat-form-collapsed-height.ts b/tools/ui/src/lib/components/app/chat/ChatForm/chat-form-collapsed-height.ts
new file mode 100644
index 000000000..738152bfc
--- /dev/null
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/chat-form-collapsed-height.ts
@@ -0,0 +1,89 @@
+import { CHAT_FORM_RESIZE_MS } from '$lib/constants';
+
+export interface CollapsedHeightHoldOptions {
+ getCard: () => HTMLElement | undefined;
+ /** The editor's box, which sets its minimum and maximum height. */
+ getEditorBox: () => HTMLElement | undefined;
+ /** The element holding the editor's text. */
+ getContent: () => HTMLElement | undefined;
+ getValue: () => string;
+}
+
+/**
+ * Holds the collapsed card's real height in `--chat-form-collapsed-height` from expanding
+ * until the collapse has settled, so a quick toggle never measures a card mid-transition.
+ */
+export function createCollapsedHeightHold(options: CollapsedHeightHoldOptions) {
+ let hold: {
+ card: number;
+ content: number;
+ editorMax: number;
+ editorMin: number;
+ text: string;
+ } | null = null;
+ let releaseTimer: ReturnType<typeof setTimeout> | undefined;
+
+ const contentHeight = () => options.getContent()?.offsetHeight ?? 0;
+
+ function setHeight(px: number | null) {
+ const style = options.getCard()?.style;
+
+ if (px === null) style?.removeProperty('--chat-form-collapsed-height');
+ else style?.setProperty('--chat-form-collapsed-height', `${px}px`);
+ }
+
+ /** Before the form expands. */
+ function start() {
+ const card = options.getCard();
+ const box = options.getEditorBox();
+
+ clearTimeout(releaseTimer);
+
+ if (hold || !card || !box) return;
+
+ const editor = getComputedStyle(box);
+
+ hold = {
+ card: card.getBoundingClientRect().height,
+ content: contentHeight(),
+ editorMax: parseFloat(editor.maxHeight) || Infinity,
+ editorMin: parseFloat(editor.minHeight) || 0,
+ text: options.getValue()
+ };
+
+ // in place with no transition of its own, so the one the switch starts begins here
+ card.style.transition = 'none';
+ setHeight(hold.card);
+ void getComputedStyle(card).minHeight;
+ card.style.transition = '';
+ }
+
+ /** Before the form collapses: the height it settles at, held until the transition is over. */
+ function settle() {
+ if (!hold) return;
+
+ // the held height, unless the text changed meanwhile: then the height the card will
+ // have with it, its editor clamped as when collapsed
+ const held = hold;
+ const editorBox = (content: number) =>
+ Math.max(held.editorMin, Math.min(content, held.editorMax));
+
+ setHeight(
+ options.getValue() === held.text
+ ? held.card
+ : held.card - editorBox(held.content) + editorBox(contentHeight())
+ );
+
+ clearTimeout(releaseTimer);
+ releaseTimer = setTimeout(() => {
+ hold = null;
+ setHeight(null);
+ }, CHAT_FORM_RESIZE_MS);
+ }
+
+ function destroy() {
+ clearTimeout(releaseTimer);
+ }
+
+ return { destroy, settle, start };
+}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/chat-form-expand.ts b/tools/ui/src/lib/components/app/chat/ChatForm/chat-form-expand.ts
new file mode 100644
index 000000000..2ddd3a7cb
--- /dev/null
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/chat-form-expand.ts
@@ -0,0 +1,63 @@
+import { hasMarkdownStructure } from '$lib/components/app/content/MarkdownEditor/markdown-structure';
+import { CHAT_FORM_EXPAND_MIN_LINES } from '$lib/constants';
+
+/** The collapsed card's `rounded-4xl` on a phone. */
+const COLLAPSED_CARD_RADIUS = '2rem';
+
+export type SheetBox = { height: number; left: number; right: number; top: number };
+/** Where the card and its actions are on screen, and the card's top padding. */
+export type SheetRects = { actionsTop: number; card: SheetBox; padTop: number };
+export type SheetFrame = { actions: Keyframe; card: Keyframe };
+
+/**
+ * Should a message expand the chat form on its own? Once it has several lines, or a
+ * markdown block in its finished lines: the line still being typed does not count, as
+ * a lone `-`, `#` or `1.` already parses as a block.
+ */
+export function shouldExpandChatForm(text: string): boolean {
+ const finishedLines = text.slice(0, text.lastIndexOf('\n') + 1);
+
+ return (
+ text.split('\n').length >= CHAT_FORM_EXPAND_MIN_LINES || hasMarkdownStructure(finishedLines)
+ );
+}
+
+/** The laid-out phone sheet as it is: full screen, square. */
+export const SHEET_AT_REST: SheetFrame = {
+ actions: { top: '0px' },
+ card: { borderRadius: '0px', clipPath: 'inset(0 0 0 0 round 0px)', top: '0px' }
+};
+
+/**
+ * The laid-out `sheet` drawn as the collapsed card at `card`: moved there, clipped to its
+ * edges, its actions held at their place. Offsets, not transforms: WebKit composites
+ * transforms apart from the clip, and the two drift.
+ */
+export function sheetFrameAt(sheet: SheetRects, card: SheetRects): SheetFrame {
+ const rise = Math.max(0, sheet.padTop - card.padTop);
+ const dy = card.card.top - sheet.card.top - rise;
+ const clip = [
+ rise,
+ sheet.card.right - card.card.right,
+ sheet.card.height - rise - card.card.height,
+ card.card.left - sheet.card.left
+ ].map((px) => `${Math.max(0, px)}px`);
+
+ return {
+ actions: { top: `${card.actionsTop - sheet.actionsTop - dy}px` },
+ card: {
+ borderRadius: COLLAPSED_CARD_RADIUS,
+ clipPath: `inset(${clip.join(' ')} round ${COLLAPSED_CARD_RADIUS})`,
+ top: `${dy}px`
+ }
+ };
+}
+
+/** `rects` moved down the screen by `dy`. */
+export function shiftSheetRects(rects: SheetRects, dy: number): SheetRects {
+ return {
+ ...rects,
+ actionsTop: rects.actionsTop + dy,
+ card: { ...rects.card, top: rects.card.top + dy }
+ };
+}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
index 08a394bb4..bed8e6dba 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
@@ -342,7 +342,7 @@
function focusMainChatForm() {
if (deviceStore.isMobile) return;
- document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus();
+ document.querySelector<HTMLElement>('.chat-screen-form-wrapper .cm-content')?.focus();
}
async function handleSaveEdit() {
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
index 954e7e374..ee07dcdee 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
@@ -16,9 +16,12 @@
} from '$lib/components/app';
import {
CHAT_FORM_BOTTOM_OFFSET,
+ CHAT_FORM_CHROME_FADE_MS,
+ CHAT_FORM_EASE_OUT,
CHAT_FORM_MOVE_IN_EASING,
CHAT_FORM_MOVE_IN_MS,
CHAT_FORM_MOVE_OUT_EASING,
+ CHAT_FORM_MOVE_OUT_FADE_END,
CHAT_FORM_MOVE_OUT_MS,
CHAT_FORM_MOVE_SHIFT_PX,
CHAT_FORM_MOVE_TO_BOTTOM_IN_MS,
@@ -36,10 +39,12 @@
conversationsStore,
deviceStore,
serverStore,
- settingsStore
+ settingsStore,
+ uiStore
} from '$lib/stores';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
import { tick, untrack } from 'svelte';
+ import { fade } from 'svelte/transition';
let { showCenteredEmpty = false } = $props();
@@ -51,7 +56,11 @@
);
// where the form is drawn: follows `isEmpty` once the form has faded out
let isFormCentered = $state(untrack(() => isEmpty));
- let isFormMoving = false;
+ // it fades while it moves between the centered and bottom positions (see moveForm);
+ // otherwise its position eases with the form expanding and collapsing
+ let isFormMoving = $state(false);
+ let isFormHeldAtBottom = $state(false);
+ let isFormLifted = $derived(isFormCentered && !isFormHeldAtBottom);
let formWrapper: HTMLElement | undefined = $state();
let chatScreenForm: ChatScreenForm | undefined = $state();
let activeErrorDialog = $derived(chatStore.errorDialogState);
@@ -67,6 +76,9 @@
return CHAT_FORM_BOTTOM_OFFSET.phoneBrowser;
});
+ // how far the form floats in from the screen's sides on a phone (its wrapper's
+ // left and right below); the expanded sheet insets its content by as much
+ let chatFormSideInset = $derived(deviceStore.isStandalone ? '1rem' : '0.5rem');
// The page is the scroller: it follows new content while the reader sits at
// the bottom, and otherwise leaves their position alone.
@@ -104,6 +116,18 @@
}
async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
+ // before the cleared form collapses: it fades into the conversation from the bottom
+ isFormHeldAtBottom = isEmpty && uiStore.isComposerExpanded;
+
+ try {
+ return await sendMessage(message, files);
+ } finally {
+ // the send failed or was refused: the new chat stays, and so may the centering
+ if (isEmpty) isFormHeldAtBottom = false;
+ }
+ }
+
+ async function sendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
const plainFiles = files ? $state.snapshot(files) : undefined;
const result = plainFiles
? await parseFilesToMessageExtras(plainFiles, activeModel.activeModelId ?? undefined)
@@ -177,9 +201,17 @@
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
const shift = reduceMotion ? 0 : (toCenter ? -1 : 1) * CHAT_FORM_MOVE_SHIFT_PX;
const shown = { opacity: 1, transform: 'translateY(0)' };
- // `transform` composes with the `translate` that centers the form
+ // `transform` moves the form on top of the offset that centers it
const leave = formWrapper.animate(
- [shown, { opacity: 0, transform: `translateY(${shift}px)` }],
+ [
+ shown,
+ {
+ offset: CHAT_FORM_MOVE_OUT_FADE_END,
+ opacity: 0,
+ transform: `translateY(${shift * CHAT_FORM_MOVE_OUT_FADE_END}px)`
+ },
+ { opacity: 0, transform: `translateY(${shift}px)` }
+ ],
{
duration: toCenter ? CHAT_FORM_MOVE_OUT_MS : CHAT_FORM_MOVE_TO_BOTTOM_OUT_MS,
easing: CHAT_FORM_MOVE_OUT_EASING,
@@ -191,6 +223,8 @@
isFormCentered = toCenter;
await tick();
+ // lay the new position out while the bottom transition is off, so it does not slide
+ formWrapper?.getBoundingClientRect();
// cancelled in the same frame the entry starts, so it never flashes
leave.cancel();
@@ -202,6 +236,7 @@
isFormCentered = isEmpty;
isFormMoving = false;
+ isFormHeldAtBottom = false;
}
$effect(() => {
@@ -227,6 +262,12 @@
return () => stick.detach();
});
+
+ // the form growing (the editor expanding, more lines) keeps the latest message
+ // in view above it while the reader sits at the bottom
+ $effect(() => {
+ if (formWrapper) return stick.followSizeOf(formWrapper);
+ });
</script>
{#if dragAndDrop.isDragOver}
@@ -240,6 +281,7 @@
{:else}
<div
style:--chat-form-bottom-position={chatFormBottomPosition}
+ style:--chat-form-side-inset={chatFormSideInset}
class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-[calc(100dvh-var(--chat-tabs-offset,0px))] px-4 md:py-0 pt-(--page-top) pb-48 md:pb-4"
ondragenter={dragAndDrop.dragHandlers.dragenter}
ondragleave={dragAndDrop.dragHandlers.dragleave}
@@ -247,20 +289,26 @@
ondrop={dragAndDrop.dragHandlers.drop}
role="main"
>
- <!-- a phone pins the gauge to the top right, across from the menu toggle -->
- {#if deviceStore.isMobile && showContextGauge.current}
- <ChatFormContextGauge
- class="sidebar-reveal-pin-top fixed top-(--corner-control-top) right-4 z-20 size-10 rounded-full bg-muted/60 border border-border/20 shadow-sm backdrop-blur-sm"
- corner
- />
+ <!-- a phone pins the gauge to the top right, across from the menu toggle; like the toggle,
+ it steps aside while the expanded chat form covers the screen and its collapse key -->
+ {#if deviceStore.isMobile && showContextGauge.current && !uiStore.isComposerExpanded}
+ <div transition:fade={{ duration: CHAT_FORM_CHROME_FADE_MS }}>
+ <ChatFormContextGauge
+ class="sidebar-reveal-pin-top fixed top-(--corner-control-top) right-4 z-20 size-10 rounded-full bg-muted/60 border border-border/20 shadow-sm backdrop-blur-sm"
+ corner
+ />
+ </div>
{/if}
<!-- a phone's chat shows its title and working directory between the corner keys; a new
- chat has neither yet, and offers the directory in the + menu instead -->
- {#if deviceStore.isMobile && page.params.id}
- <ChatScreenMobileHeader
- onWorkingDirectoryClick={() => chatScreenForm?.openWorkingDirectoryPicker()}
- />
+ chat has neither yet, and offers the directory in the + menu instead. Like the keys,
+ it steps aside while the expanded chat form covers the screen -->
+ {#if deviceStore.isMobile && page.params.id && !uiStore.isComposerExpanded}
+ <div transition:fade={{ duration: CHAT_FORM_CHROME_FADE_MS }}>
+ <ChatScreenMobileHeader
+ onWorkingDirectoryClick={() => chatScreenForm?.openWorkingDirectoryPicker()}
+ />
+ </div>
{/if}
{#if !isEmpty}
@@ -275,27 +323,34 @@
<div
bind:this={formWrapper}
+ style:--chat-form-resize-ease={isFormLifted ? CHAT_FORM_EASE_OUT : undefined}
style:padding-top="var(--chat-form-padding-top)"
class={[
- // moves between the centered (empty chat) and bottomed positions with
- // a fade, see moveForm. Its height in the page is the same in both
- // (the padding stays, the desktop greeting floats above the form),
- // so the messages never shift while it moves
+ // moves between the centered and bottom positions with a fade (see moveForm), at
+ // the same height in the page, so the messages never shift
'pointer-events-none md:sticky fixed mt-auto max-md:z-10',
// a phone holds it in place on screen while the page is slid aside
deviceStore.isMobile && 'sidebar-reveal-pin-bottom',
- deviceStore.isStandalone
- ? 'bottom-6 right-4 left-4'
- : deviceStore.isIOSSafari
- ? 'bottom-2 left-2 right-2'
- : 'bottom-3 right-2 left-2',
- 'md:bottom-4',
- isFormCentered
- ? 'md:translate-y-[calc(-50dvh+7rem)] 2xl:translate-y-[calc(-50dvh+4rem)]'
- : ''
+ // on a phone the expanded form is a sheet: flush with the screen's sides and bottom
+ uiStore.isComposerExpanded
+ ? 'bottom-0 right-0 left-0'
+ : deviceStore.isStandalone
+ ? 'bottom-6 right-4 left-4'
+ : deviceStore.isIOSSafari
+ ? 'bottom-2 left-2 right-2'
+ : 'bottom-3 right-2 left-2',
+ // Lifted to the center on a new chat, by its sticky bottom offset and not a
+ // translate, which a quick reversal starts a frame ahead of the card's height
+ isFormLifted &&
+ 'md:[--chat-form-drop:calc(50dvh-7rem)] 2xl:[--chat-form-drop:calc(50dvh-4rem)]',
+ isFormLifted && !uiStore.isComposerExpanded
+ ? 'md:bottom-[calc(var(--chat-form-drop)+1rem)]'
+ : 'md:bottom-4',
+ !isFormMoving &&
+ 'transition-[left,right,bottom] duration-400 ease-[var(--chat-form-resize-ease,cubic-bezier(0.3,1.12,0.5,1))] max-md:transition-none motion-reduce:transition-none'
]}
>
- <ChatScreenGreeting isEmpty={isFormCentered} />
+ <ChatScreenGreeting faded={uiStore.isComposerExpanded} isEmpty={isFormLifted} />
<ChatScreenServerError />
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
index 6f3a13d45..fe382bd07 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
@@ -2,10 +2,11 @@
import { afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import { ChatForm } from '$lib/components/app';
+ import { SETTINGS_KEYS } from '$lib/constants';
import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
- import { deviceStore } from '$lib/stores';
+ import { deviceStore, settingsStore } from '$lib/stores';
import { hapticTap } from '$lib/utils';
- import { onMount } from 'svelte';
+ import { onMount, tick } from 'svelte';
interface Props {
class?: string;
@@ -93,11 +94,14 @@
uploadedFiles = [];
clearDraft();
+ // called in the same task: the page still sees the form as it was sent (expanded or not)
const success = await onSend?.(messageToSend, filesToSend);
if (!success) {
message = messageToSend;
uploadedFiles = filesToSend;
+ await tick();
+ chatFormRef?.expandForMessage();
}
}
@@ -151,11 +155,13 @@
bind:value={message}
class="mx-auto max-w-3xl {className}"
{disabled}
+ expandable
{isLoading}
onFilesAdd={handleFilesAdd}
{onStop}
onSubmit={handleSubmit}
onSystemPromptClick={handleSystemPromptClick}
onUploadedFileRemove={handleUploadedFileRemove}
+ singleRow={settingsStore.config[SETTINGS_KEYS.SINGLE_ROW_CHAT_FORM] !== false}
/>
</div>
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte
index 4174eec2c..11c43290d 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte
@@ -1,12 +1,14 @@
<script lang="ts">
- import { CHAT_GREETING_ENTER_DELAY_MS } from '$lib/constants';
+ import { CHAT_FORM_RESIZE_MS, CHAT_GREETING_ENTER_DELAY_MS } from '$lib/constants';
import { serverStore } from '$lib/stores';
interface Props {
+ /** Transparent but still in place, e.g. behind the expanded chat form. */
+ faded?: boolean;
isEmpty: boolean;
}
- let { isEmpty = false }: Props = $props();
+ let { faded = false, isEmpty = false }: Props = $props();
let hasEntered = $state(false);
@@ -33,10 +35,23 @@
]);
</script>
+<!-- fades out right away, and back in once the collapsing form has settled -->
<div
+ style:transition-delay="{faded ? 0 : CHAT_FORM_RESIZE_MS}ms"
class={[
+ 'transition-opacity duration-300',
+ faded && 'opacity-0',
'pointer-events-none mb-4 hidden px-4 text-center text-balance',
- isEmpty && 'mb-[calc(50dvh-8rem)] md:mb-8 pointer-events-auto block!',
+ isEmpty && 'md:mb-8 block!',
+ isEmpty && !faded && 'pointer-events-auto',
+ // held just above the middle of a phone screen, so the form growing into its
+ // expanded editor never pushes it: it only fades. Its place is counted from the
+ // form's wrapper, which floats above the screen's bottom until it is a sheet
+ isEmpty && 'max-md:fixed max-md:inset-x-0 max-md:mb-0',
+ isEmpty &&
+ (faded
+ ? 'max-md:bottom-[calc(50dvh+1rem)]'
+ : 'max-md:bottom-[calc(50dvh+1rem-var(--chat-form-bottom-position,0px))]'),
// out of the page flow on desktop, just above the form: inside the form
// wrapper's top padding, so showing it never changes the wrapper's height
isEmpty && 'md:absolute md:inset-x-0 md:bottom-[calc(100%-var(--chat-form-padding-top))]'
diff --git a/tools/ui/src/lib/components/app/content/MarkdownEditor/MarkdownEditor.svelte b/tools/ui/src/lib/components/app/content/MarkdownEditor/MarkdownEditor.svelte
index 8571cded1..94f4cc0c5 100644
--- a/tools/ui/src/lib/components/app/content/MarkdownEditor/MarkdownEditor.svelte
+++ b/tools/ui/src/lib/components/app/content/MarkdownEditor/MarkdownEditor.svelte
@@ -6,8 +6,12 @@
type MarkdownLivePreviewOptions,
markdownPlainText
} from './markdown-live-preview';
- import { isInCodeBlock } from './markdown-syntax';
- import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
+ import {
+ defaultKeymap,
+ history,
+ historyKeymap,
+ insertNewlineAndIndent
+ } from '@codemirror/commands';
import {
insertNewlineContinueMarkup,
markdownKeymap,
@@ -89,7 +93,8 @@
const valueSync = Annotation.define<boolean>();
let host: HTMLDivElement | undefined = $state();
- let view: EditorView | undefined;
+ // reactive, so what follows the editor's DOM (see getContentDOM) starts once it exists
+ let view = $state.raw<EditorView>();
let previewCode = $state('');
let previewLanguage = $state('text');
let previewOpen = $state(false);
@@ -139,8 +144,13 @@
extensions: [
customCompartment.of(extensions),
history(),
- // Shift+Enter is a plain line break that still continues lists and quotes
- keymap.of([{ key: 'Shift-Enter', run: insertNewlineContinueMarkup }]),
+ // Shift+Enter and Ctrl/Cmd+Enter add a line, continuing lists and quotes
+ keymap.of(
+ ['Shift-Enter', 'Mod-Enter', 'Ctrl-Enter'].map((key) => ({
+ key,
+ run: (target) => insertNewlineContinueMarkup(target) || insertNewlineAndIndent(target)
+ }))
+ ),
keymap.of([...markdownKeymap, ...defaultKeymap, ...historyKeymap]),
markdownSupport,
previewCompartment.of(previewExtension()),
@@ -243,16 +253,21 @@
loadHighlightTheme(mode.current === ColorMode.DARK);
});
+ /** The editor's content element, once mounted (e.g. to follow its height). */
+ export function getContentDOM(): HTMLElement | undefined {
+ return view?.contentDOM;
+ }
+
+ /** Where the cursor is on screen, e.g. to place a popover by it. */
+ export function getCaretRect() {
+ return view?.coordsAtPos(view.state.selection.main.head) ?? null;
+ }
+
/** Cursor offset in the markdown source. */
export function getCaretOffset(): number {
return view?.state.selection.main.head ?? 0;
}
- /** Is the cursor in a code block (fenced, closed or still open, or indented)? */
- export function isCaretInCodeBlock(): boolean {
- return view ? isInCodeBlock(view.state, view.state.selection.main.head) : false;
- }
-
/** Focus the editor with the cursor at `offset` (default: the end of the text). */
export function focusAt(offset?: number) {
if (!view) return;
diff --git a/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-structure.ts b/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-structure.ts
new file mode 100644
index 000000000..b23dcd26a
--- /dev/null
+++ b/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-structure.ts
@@ -0,0 +1,16 @@
+import { markdownLanguage } from '@codemirror/lang-markdown';
+
+/**
+ * Does `source` hold any markdown block other than plain paragraphs (a heading,
+ * list, quote, code block, table, rule, ...)? Inline markup inside a paragraph
+ * does not count.
+ */
+export function hasMarkdownStructure(source: string): boolean {
+ const tree = markdownLanguage.parser.parse(source);
+
+ for (let node = tree.topNode.firstChild; node; node = node.nextSibling) {
+ if (node.name !== 'Paragraph') return true;
+ }
+
+ return false;
+}
diff --git a/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-syntax.ts b/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-syntax.ts
index 4f24518bf..4e8c94bb2 100644
--- a/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-syntax.ts
+++ b/tools/ui/src/lib/components/app/content/MarkdownEditor/markdown-syntax.ts
@@ -1,34 +1,10 @@
-// Questions about the markdown under a position, answered from the editor's syntax
-// tree, so they agree with what the live preview renders (``` and ~~~ fences,
-// indented code, inline code).
+// Where the code is in the markdown, answered from the editor's syntax tree, so it agrees
+// with what the live preview renders (``` and ~~~ fences, indented code, inline code).
-import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
+import { syntaxTree } from '@codemirror/language';
import type { EditorState } from '@codemirror/state';
-import type { SyntaxNode } from '@lezer/common';
const CODE_NODE_NAMES = new Set(['CodeBlock', 'FencedCode', 'InlineCode']);
-const PARSE_TIMEOUT_MS = 50;
-
-/**
- * Is `pos` inside a code block, fenced (closed, or still open while it is being
- * typed) or indented? Right after a closing fence counts as outside.
- */
-export function isInCodeBlock(state: EditorState, pos: number): boolean {
- const tree = ensureSyntaxTree(state, pos, PARSE_TIMEOUT_MS) ?? syntaxTree(state);
-
- for (let node: SyntaxNode | null = tree.resolveInner(pos, -1); node; node = node.parent) {
- if (node.name === 'CodeBlock') return true;
-
- if (node.name === 'FencedCode') {
- const closing = node.lastChild;
- const closed = closing?.name === 'CodeMark' && closing.from > node.from;
-
- return !(closed && pos >= node.to);
- }
- }
-
- return false;
-}
/** The [from, to) ranges of code (blocks and inline) between `from` and `to`. */
export function codeRanges(state: EditorState, from: number, to: number): [number, number][] {
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
index 850de8b4b..661a3df90 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
@@ -56,6 +56,9 @@
let dockTimeout: ReturnType<typeof setTimeout> | undefined;
const isOnMobile = $derived(deviceStore.isMobile);
+ const isHiddenByChatForm = $derived(
+ isOnMobile && uiStore.isComposerExpanded && !uiStore.isSidebarExpanded
+ );
const alwaysShowOnDesktop = $derived(settingsStore.config.alwaysShowSidebarOnDesktop as boolean);
// the collapsed sidebar shows only its logo until it is peeked at, which fades the rest in
const isStripHidden = $derived(!uiStore.isSidebarExpanded && !isOnMobile && !isPeeking);
@@ -544,9 +547,13 @@
'md:bg-muted/60 md:backdrop-blur-xl max-md:shadow-md',
isFloating && 'md:-mr-[calc(var(--sidebar-width)-3rem)]',
isStripHidden && 'md:pointer-events-none',
+ // on a phone the expanded chat form fills the screen, so the logo button over
+ // its corner fades away (and goes inert) until the form collapses
+ 'max-md:transition-opacity',
+ isHiddenByChatForm && 'opacity-0',
uiStore.isSidebarExpanded && 'is-expanded'
]}
- inert={isOnMobile && !uiStore.isSidebarExpanded}
+ inert={isHiddenByChatForm || (isOnMobile && !uiStore.isSidebarExpanded)}
onfocusin={peek}
onfocusout={handleAsideFocusOut}
onmouseleave={() => (isPeeking = false)}
diff --git a/tools/ui/src/lib/constants/chat-form.constants.ts b/tools/ui/src/lib/constants/chat-form.constants.ts
index aaf40226b..263fc8a87 100644
--- a/tools/ui/src/lib/constants/chat-form.constants.ts
+++ b/tools/ui/src/lib/constants/chat-form.constants.ts
@@ -3,6 +3,10 @@ export const PROMPT_CONTENT_SEPARATOR = '\n\n';
export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
export const PROMPT_TRIGGER_PREFIX = '/';
export const NEW_CHAT_DRAFT_KEY = '__new_chat__';
+/** Lines of text from which the main chat form expands into its large editor. */
+export const CHAT_FORM_EXPAND_MIN_LINES = 4;
+/** Below this text height (a 24px line and change) the main chat form keeps to one row. */
+export const CHAT_FORM_SINGLE_ROW_MAX_TEXT_PX = 36;
/**
* How far the chat form floats above the bottom of the screen: a phone browser's toolbar is
@@ -22,13 +26,15 @@ export const CHAT_FORM_BOTTOM_OFFSET = {
* lands, instead of sliding across the column. The exit uses a gentler curve
* so the form stays visible while it travels; the entry settles quickly.
*/
-export const CHAT_FORM_MOVE_OUT_MS = 140;
+export const CHAT_FORM_MOVE_OUT_MS = 100;
export const CHAT_FORM_MOVE_IN_MS = 180;
// opening a chat moves the form to the bottom: quicker, so the chat is
// there right away
-export const CHAT_FORM_MOVE_TO_BOTTOM_OUT_MS = 80;
-export const CHAT_FORM_MOVE_TO_BOTTOM_IN_MS = 120;
-export const CHAT_FORM_MOVE_SHIFT_PX = 16;
+export const CHAT_FORM_MOVE_TO_BOTTOM_OUT_MS = 90;
+export const CHAT_FORM_MOVE_TO_BOTTOM_IN_MS = 160;
+export const CHAT_FORM_MOVE_SHIFT_PX = 10;
+/** Share of the exit by which the form is gone: it vanishes just before the end of its drift. */
+export const CHAT_FORM_MOVE_OUT_FADE_END = 0.8;
export const CHAT_FORM_MOVE_OUT_EASING = 'cubic-bezier(0.33, 1, 0.68, 1)';
export const CHAT_FORM_MOVE_IN_EASING = 'cubic-bezier(0.23, 1, 0.32, 1)';
@@ -37,3 +43,25 @@ export const CHAT_FORM_MOVE_IN_EASING = 'cubic-bezier(0.23, 1, 0.32, 1)';
* halfway through the form's fade-in so both read as one motion.
*/
export const CHAT_GREETING_ENTER_DELAY_MS = CHAT_FORM_MOVE_IN_MS / 2;
+
+/**
+ * The main chat form growing into its expanded editor and back (the
+ * `duration-400` of its height and position transitions). After a collapse
+ * the greeting waits for it, so it fades in over a form that has settled.
+ */
+export const CHAT_FORM_RESIZE_MS = 400;
+
+/**
+ * On a phone the expanded form is a full-screen sheet, which moves more slowly
+ * (see useChatFormExpansion).
+ */
+export const CHAT_FORM_SHEET_MS = 500;
+
+/**
+ * An ease-out without overshoot, for moves that must land exactly: the phone
+ * sheet, and the new chat's form, whose top edge an overshoot would lift.
+ */
+export const CHAT_FORM_EASE_OUT = 'cubic-bezier(0.32, 0.72, 0, 1)';
+
+/** The phone's corner keys and header stepping aside for the expanded form, and back. */
+export const CHAT_FORM_CHROME_FADE_MS = 150;
diff --git a/tools/ui/src/lib/constants/pwa.constants.ts b/tools/ui/src/lib/constants/pwa.constants.ts
index e807f4a97..bd6c95346 100644
--- a/tools/ui/src/lib/constants/pwa.constants.ts
+++ b/tools/ui/src/lib/constants/pwa.constants.ts
@@ -6,6 +6,7 @@
import { APP_NAME } from './app.constants';
export const MEDIA_QUERIES = {
+ COARSE_POINTER: '(pointer: coarse)',
DISPLAY_MODE_STANDALONE: '(display-mode: standalone)',
PREFERS_DARK: '(prefers-color-scheme: dark)',
PREFERS_LIGHT: '(prefers-color-scheme: light)'
diff --git a/tools/ui/src/lib/constants/settings-keys.constants.ts b/tools/ui/src/lib/constants/settings-keys.constants.ts
index 947bd5cc2..456b7017d 100644
--- a/tools/ui/src/lib/constants/settings-keys.constants.ts
+++ b/tools/ui/src/lib/constants/settings-keys.constants.ts
@@ -70,6 +70,7 @@ export const SETTINGS_KEYS = {
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
SHOW_TOOL_CALL_IN_PROGRESS: 'showToolCallInProgress',
+ SINGLE_ROW_CHAT_FORM: 'singleRowChatForm',
SYMBOLIC_MATH_ENABLED: 'symbolicMathEnabled',
SYSTEM_MESSAGE: 'systemMessage',
// Sampling
diff --git a/tools/ui/src/lib/constants/settings.constants.ts b/tools/ui/src/lib/constants/settings.constants.ts
index 25c708f4f..65e192949 100644
--- a/tools/ui/src/lib/constants/settings.constants.ts
+++ b/tools/ui/src/lib/constants/settings.constants.ts
@@ -107,7 +107,7 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
},
{
defaultValue: true,
- help: 'Use Enter to send messages and Shift + Enter for new lines. When disabled, use Ctrl/Cmd + Enter.',
+ help: 'Use Enter to send messages, and Shift + Enter or Ctrl/Cmd + Enter for new lines. When disabled, and always on a touch screen, Enter adds a line and Ctrl/Cmd + Enter sends.',
key: SETTINGS_KEYS.SEND_ON_ENTER,
label: 'Send message on Enter',
type: SettingsFieldType.CHECKBOX
@@ -292,6 +292,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
label: 'Disable automatic scroll',
type: SettingsFieldType.CHECKBOX
},
+ {
+ defaultValue: true,
+ help: 'On desktop, keep a one-line message on the same row as the + button, the model selector and the send button. A second line, or a line too long for the row, brings back the two-row form.',
+ key: SETTINGS_KEYS.SINGLE_ROW_CHAT_FORM,
+ label: 'Single-row chat form for short messages',
+ type: SettingsFieldType.CHECKBOX
+ },
{
defaultValue: false,
help: 'Always keep the sidebar visible on desktop instead of auto-hiding it.',
diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts
index 46066b25c..14bb7e400 100644
--- a/tools/ui/src/lib/constants/ui.constants.ts
+++ b/tools/ui/src/lib/constants/ui.constants.ts
@@ -56,6 +56,8 @@ export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
export const UI_DATA_ATTRS = {
ACTIVE: 'data-active',
ACTIVE_TAB: 'data-active-tab',
+ /** On <html> while the main chat form is expanded (a full-screen sheet on a phone). */
+ CHAT_FORM_SHEET: 'data-chat-form-sheet',
CONVERSATION_ROW: 'data-conversation-row',
HIGHLIGHT_THEME_PREVIEW: 'data-highlight-theme-preview',
PICKER_INDEX: 'data-picker-index',
diff --git a/tools/ui/src/lib/hooks/use-chat-form-expansion.svelte.ts b/tools/ui/src/lib/hooks/use-chat-form-expansion.svelte.ts
new file mode 100644
index 000000000..c1286b536
--- /dev/null
+++ b/tools/ui/src/lib/hooks/use-chat-form-expansion.svelte.ts
@@ -0,0 +1,317 @@
+import { useKeyboardInset } from './use-keyboard-inset.svelte';
+import {
+ type CollapsedHeightHoldOptions,
+ createCollapsedHeightHold
+} from '$lib/components/app/chat/ChatForm/chat-form-collapsed-height';
+import {
+ SHEET_AT_REST,
+ type SheetFrame,
+ sheetFrameAt,
+ type SheetRects,
+ shiftSheetRects,
+ shouldExpandChatForm
+} from '$lib/components/app/chat/ChatForm/chat-form-expand';
+import { CHAT_FORM_EASE_OUT, CHAT_FORM_SHEET_MS, UI_DATA_ATTRS } from '$lib/constants';
+import { deviceStore, uiStore } from '$lib/stores';
+import { flushSync, onDestroy, tick, untrack } from 'svelte';
+
+export interface UseChatFormExpansionOptions extends CollapsedHeightHoldOptions {
+ /** Whether this form expands at all. */
+ isEnabled: () => boolean;
+ /** What the message belongs to (the conversation): another one starts collapsed. */
+ getResetKey: () => unknown;
+ /** The box around the form's actions. */
+ getActions: () => HTMLElement | undefined;
+ focusInput: () => void;
+}
+
+/**
+ * The chat form's expanded editor: entered by hand, or on a desktop once the message being
+ * written asks for it (see shouldExpandChatForm), kept while it has text, left once it is
+ * cleared or sent. Collapsing by hand holds until then too. On a phone it is a full-screen
+ * sheet that slides from and back to the card (see sheetFrameAt).
+ */
+export function useChatFormExpansion(options: UseChatFormExpansionOptions) {
+ let isExpanded = $state(false);
+ let isCollapsedByUser = false;
+ let hadText = false;
+ let wasExpanded = false;
+ let isSheetMoving = $state(false);
+ // WebKit builds the card's backdrop-filter layer with the animation's last clip when
+ // the blur comes back as the card lands: it waits for the move to be over
+ let isBlurHeld = $state(false);
+ let collapsedRects: SheetRects | null = null;
+ // iOS Safari resizes its viewport while the sheet is up: the collapsed card's place
+ // is held relative to the sheet's bottom edge
+ let sheetBottomAtOpen = 0;
+
+ $effect(() => {
+ if (!options.isEnabled()) return;
+
+ const text = options.getValue() ?? '';
+
+ untrack(() => {
+ if (!text.trim()) {
+ // cleared or sent
+ if (hadText) reset();
+
+ hadText = false;
+
+ return;
+ }
+
+ hadText = true;
+ });
+ });
+
+ let resetKey = untrack(options.getResetKey);
+
+ $effect(() => {
+ const key = options.getResetKey();
+
+ if (key === resetKey) return;
+
+ resetKey = key;
+ untrack(reset);
+ });
+
+ /** Straight back to the small form, without the sheet's slide, free to expand again. */
+ function reset() {
+ isCollapsedByUser = false;
+
+ if (isSheetMoving) clearSheetAnimations();
+
+ isExpanded = false;
+ }
+
+ /**
+ * Expands for what the user writes or pastes, not for a draft restored on opening a
+ * chat. A phone only expands by hand: the sheet switching in mid-typing moves the text
+ * under the keyboard.
+ */
+ function handleInput() {
+ if (!options.isEnabled() || isExpanded || isCollapsedByUser || deviceStore.isMobile) return;
+
+ isExpanded = shouldExpandChatForm(options.getValue() ?? '');
+ }
+
+ $effect(() => {
+ if (options.isEnabled()) uiStore.isComposerExpanded = isExpanded;
+ });
+
+ // the phone sheet fills the screen, so its actions would sit under an iOS keyboard
+ useKeyboardInset(() => isExpanded && deviceStore.isMobile);
+
+ // the page around the phone sheet takes its fill (see app.css) once it has settled:
+ // repainting the whole page under it mid-move costs frames
+ $effect(() => {
+ if (!options.isEnabled() || !isExpanded || isSheetMoving) return;
+
+ document.documentElement.setAttribute(UI_DATA_ATTRS.CHAT_FORM_SHEET, '');
+
+ return () => document.documentElement.removeAttribute(UI_DATA_ATTRS.CHAT_FORM_SHEET);
+ });
+
+ function animatesSheet(): boolean {
+ return deviceStore.isMobile && !matchMedia('(prefers-reduced-motion: reduce)').matches;
+ }
+
+ function sheetBottom(): number {
+ return options.getCard()?.closest('form')?.getBoundingClientRect().bottom ?? 0;
+ }
+
+ function measure(): SheetRects | null {
+ const card = options.getCard();
+ const actions = options.getActions();
+
+ if (!card || !actions) return null;
+
+ const { height, left, right, top } = card.getBoundingClientRect();
+
+ return {
+ actionsTop: actions.getBoundingClientRect().top,
+ card: { height, left, right, top },
+ padTop: parseFloat(getComputedStyle(card).paddingTop) || 0
+ };
+ }
+
+ /**
+ * Plays the frames on the card and its actions, none held (WebKit keeps drawing a held
+ * clip after the layout changed). `onEnd` switches the layout as a collapse ends, before
+ * that frame is painted.
+ */
+ function animateSheet(
+ frames: SheetFrame[],
+ duration = CHAT_FORM_SHEET_MS,
+ onEnd?: () => void
+ ): Promise<unknown> {
+ const card = options.getCard();
+ const actions = options.getActions();
+
+ // the form went away mid-move
+ if (!card || !actions) return Promise.resolve();
+
+ const keyframeOptions = { duration, easing: CHAT_FORM_EASE_OUT };
+ const animations = [
+ card.animate(
+ frames.map((frame) => frame.card),
+ keyframeOptions
+ ),
+ actions.animate(
+ frames.map((frame) => frame.actions),
+ keyframeOptions
+ )
+ ];
+
+ if (onEnd) animations[0].addEventListener('finish', onEnd, { once: true });
+
+ return Promise.all(animations.map((animation) => animation.finished));
+ }
+
+ function clearSheetAnimations() {
+ for (const el of [options.getCard(), options.getActions()]) {
+ el?.getAnimations()
+ .filter((animation) => !(animation instanceof CSSTransition))
+ .forEach((animation) => animation.cancel());
+ }
+ }
+
+ async function expandSheet(from: SheetRects) {
+ isSheetMoving = true;
+ // measure once the page has laid the form out flush with the screen, before a paint
+ await tick();
+ sheetBottomAtOpen = sheetBottom();
+
+ try {
+ const sheet = measure();
+
+ if (sheet) await animateSheet([sheetFrameAt(sheet, from), SHEET_AT_REST]);
+ } catch {
+ // cancelled: the form went away or collapsed
+ }
+
+ clearSheetAnimations();
+ isSheetMoving = false;
+ }
+
+ async function collapseSheet() {
+ const sheet = measure();
+
+ if (!collapsedRects || !sheet) {
+ isExpanded = false;
+
+ return;
+ }
+
+ const target = shiftSheetRects(collapsedRects, sheetBottom() - sheetBottomAtOpen);
+
+ isSheetMoving = true;
+ isBlurHeld = true;
+
+ try {
+ await animateSheet([SHEET_AT_REST, sheetFrameAt(sheet, target)], CHAT_FORM_SHEET_MS, () => {
+ flushSync(() => (isExpanded = false));
+ });
+ } catch {
+ // cancelled: settle at once
+ }
+
+ isExpanded = false;
+ await tick();
+
+ // the text may have changed while expanded, and with it the collapsed height
+ const settled = measure();
+
+ if (settled && Math.abs(settled.card.top - target.card.top) > 1) {
+ const dy = target.card.top - settled.card.top;
+
+ await animateSheet(
+ [
+ { actions: {}, card: { top: `${dy}px` } },
+ { actions: {}, card: { top: '0px' } }
+ ],
+ CHAT_FORM_SHEET_MS / 2
+ ).catch(() => {});
+ clearSheetAnimations();
+ }
+
+ isSheetMoving = false;
+
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ isBlurHeld = false;
+ }
+
+ const collapsedHeight = createCollapsedHeightHold(options);
+
+ // before the DOM switches: where the collapsed card is, and how tall
+ $effect.pre(() => {
+ const expanded = isExpanded;
+
+ untrack(() => {
+ if (expanded === wasExpanded) return;
+
+ if (!expanded) {
+ collapsedHeight.settle();
+
+ return;
+ }
+
+ if (!isSheetMoving && animatesSheet()) collapsedRects = measure();
+
+ collapsedHeight.start();
+ });
+ });
+
+ // after it switched: ease the sheet in from there
+ $effect(() => {
+ const expanded = isExpanded;
+
+ untrack(() => {
+ if (expanded && !wasExpanded && collapsedRects && !isSheetMoving) {
+ void expandSheet(collapsedRects);
+ }
+
+ if (!expanded) collapsedRects = null;
+
+ wasExpanded = expanded;
+ });
+ });
+
+ onDestroy(() => {
+ collapsedHeight.destroy();
+
+ if (options.isEnabled()) uiStore.isComposerExpanded = false;
+ });
+
+ function toggle() {
+ if (isSheetMoving) return;
+
+ // collapsing an empty form leaves the next message free to expand it again
+ isCollapsedByUser = isExpanded && !!(options.getValue() ?? '').trim();
+
+ if (isExpanded && animatesSheet()) {
+ void collapseSheet();
+
+ return;
+ }
+
+ isExpanded = !isExpanded;
+ // after the switch, so the editor redrawing as it takes focus does not reach into
+ // the heights measured for it
+ options.focusInput();
+ }
+
+ return {
+ handleInput,
+ get isBlurHeld() {
+ return isBlurHeld;
+ },
+ get isExpanded() {
+ return isExpanded;
+ },
+ get isSheetMoving() {
+ return isSheetMoving;
+ },
+ toggle
+ };
+}
diff --git a/tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts b/tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts
index 8698fd4ef..4e29afed7 100644
--- a/tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts
@@ -20,6 +20,8 @@ export interface UseChatFormPickersOptions {
getCaretOffset: () => number | undefined;
setCaretOffset: (offset: number) => void;
focusInput: () => void;
+ /** Whether typing `/` or `@` opens a picker at all. */
+ opensOnInput: () => boolean;
/** Gates `/model`. */
getShowModelSelector: () => boolean;
/** Gates `/prompt`. */
@@ -118,6 +120,8 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
}
function handleInput() {
+ if (!opts.opensOnInput()) return;
+
const value = opts.getValue();
const cursor = opts.getCaretOffset() ?? value.length;
diff --git a/tools/ui/src/lib/hooks/use-keyboard-inset.svelte.ts b/tools/ui/src/lib/hooks/use-keyboard-inset.svelte.ts
new file mode 100644
index 000000000..85412ab87
--- /dev/null
+++ b/tools/ui/src/lib/hooks/use-keyboard-inset.svelte.ts
@@ -0,0 +1,39 @@
+/**
+ * Publishes how much of the screen's bottom the on-screen keyboard covers, as
+ * `--keyboard-inset` on the root, while `active()` holds; unset while no keyboard is up.
+ *
+ * iOS lays the keyboard over the page instead of resizing it, so what is pinned to the
+ * screen's bottom stays under it: the visual viewport shrinks, the layout viewport does not.
+ * Android, with `interactive-widget=resizes-content`, shrinks the page itself, so the two
+ * viewports match and this stays unset there. A pinch zoom shrinks the visual viewport too,
+ * so none is read while zoomed.
+ */
+export function useKeyboardInset(active: () => boolean) {
+ $effect(() => {
+ const viewport = window.visualViewport;
+
+ if (!active() || !viewport) return;
+
+ const root = document.documentElement;
+ const update = () => {
+ const inset =
+ viewport.scale > 1 ? 0 : window.innerHeight - viewport.height - viewport.offsetTop;
+
+ if (inset > 0) {
+ root.style.setProperty('--keyboard-inset', `${Math.round(inset)}px`);
+ } else {
+ root.style.removeProperty('--keyboard-inset');
+ }
+ };
+
+ update();
+ viewport.addEventListener('resize', update);
+ viewport.addEventListener('scroll', update);
+
+ return () => {
+ viewport.removeEventListener('resize', update);
+ viewport.removeEventListener('scroll', update);
+ root.style.removeProperty('--keyboard-inset');
+ };
+ });
+}
diff --git a/tools/ui/src/lib/hooks/use-sidebar-reveal.svelte.ts b/tools/ui/src/lib/hooks/use-sidebar-reveal.svelte.ts
index 1055c064f..34604b9c7 100644
--- a/tools/ui/src/lib/hooks/use-sidebar-reveal.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-sidebar-reveal.svelte.ts
@@ -214,6 +214,10 @@ export function useSidebarReveal() {
if (!deviceStore.isMobile || !event.isPrimary) return;
const wasExpanded = uiStore.isSidebarExpanded;
+
+ // the expanded chat form covers the screen: a drag over it is not for the sidebar
+ if (uiStore.isComposerExpanded && !wasExpanded) return;
+
const isAnywhere = !wasExpanded && event.clientX > SIDEBAR_REVEAL_EDGE_PX;
if (isAnywhere && ownsHorizontalDrag(event.target, event.currentTarget as Element)) return;
diff --git a/tools/ui/src/lib/hooks/use-stick-to-bottom.svelte.ts b/tools/ui/src/lib/hooks/use-stick-to-bottom.svelte.ts
index 78678854f..cb7c48181 100644
--- a/tools/ui/src/lib/hooks/use-stick-to-bottom.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-stick-to-bottom.svelte.ts
@@ -98,6 +98,19 @@ export class StickToBottom {
this.container = undefined;
}
+ /**
+ * Also follows `element`'s size changes, even right after a press: the grace keeps
+ * a block the reader expands from dragging the viewport, but the chat form
+ * expanding on their click must carry the conversation along. Returns the cleanup.
+ */
+ followSizeOf(element: HTMLElement): () => void {
+ const observer = new ResizeObserver(() => this.follow(true));
+
+ observer.observe(element);
+
+ return () => observer.disconnect();
+ }
+
/** Reader scrolls are the only thing that re-arms or stops following. */
handleScroll(): void {
this.measure();
@@ -138,12 +151,13 @@ export class StickToBottom {
return Math.max(0, el.scrollHeight - shown - el.scrollTop);
}
- private follow(): void {
+ private follow(ignoreGrace = false): void {
const el = this.container;
if (!el) return;
- const pressedRecently = performance.now() - this.pressedAt < INTERACTION_GRACE_MS;
+ const pressedRecently =
+ !ignoreGrace && performance.now() - this.pressedAt < INTERACTION_GRACE_MS;
const atBottom = this.distanceFromBottom(el) <= AT_BOTTOM_PX;
// with native anchoring the pin already held the bottom; write only when
diff --git a/tools/ui/src/lib/stores/device.svelte.ts b/tools/ui/src/lib/stores/device.svelte.ts
index 42aaf4589..68b14b853 100644
--- a/tools/ui/src/lib/stores/device.svelte.ts
+++ b/tools/ui/src/lib/stores/device.svelte.ts
@@ -42,12 +42,19 @@ class DeviceStore {
/** OS color scheme preference; the user override lives in settingsStore. */
readonly systemTheme = $state({ isDark: false });
+ private coarsePointer = new MediaQuery(MEDIA_QUERIES.COARSE_POINTER);
+
private mobile = new MediaQuery(`max-width: ${DEFAULT_MOBILE_BREAKPOINT - 1}px`);
get isMobile(): boolean {
return this.mobile.current;
}
+ /** A finger is the main pointer, so typing uses the on-screen keyboard. */
+ get isTouch(): boolean {
+ return this.coarsePointer.current;
+ }
+
constructor() {
if (!browser) return;
diff --git a/tools/ui/src/lib/stores/ui.svelte.ts b/tools/ui/src/lib/stores/ui.svelte.ts
index 269a06aed..86e33d6f9 100644
--- a/tools/ui/src/lib/stores/ui.svelte.ts
+++ b/tools/ui/src/lib/stores/ui.svelte.ts
@@ -67,6 +67,10 @@ class UiStore {
discoverModelsOpen = $state(false);
/** Sidebar sections showing all their chats past the preview, for this session. */
expandedSidebarSections = new SvelteSet<string>();
+
+ /** Whether the main chat form is expanded into its large editor. */
+ isComposerExpanded = $state(false);
+
/** Set while the user drags the sidebar edge, so width transitions can step aside. */
isSidebarResizing = $state(false);
diff --git a/tools/ui/src/lib/types/chat.d.ts b/tools/ui/src/lib/types/chat.d.ts
index 7527fdac4..141980089 100644
--- a/tools/ui/src/lib/types/chat.d.ts
+++ b/tools/ui/src/lib/types/chat.d.ts
@@ -356,6 +356,8 @@ export interface ChatFormActionsContext {
readonly hasVisionModality: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
- /** Opens the working directory picker; set where the + menu offers it (a phone's new chat). */
+ /** Opens the working directory picker; set where the + menu offers it. */
onWorkingDirectoryClick?: () => void;
+ /** The working directory's name in the + menu, or what sets one. */
+ readonly workingDirectoryLabel: string;
}
diff --git a/tools/ui/src/lib/utils/haptics.ts b/tools/ui/src/lib/utils/haptics.ts
index cdf5ab21f..81f9c49a8 100644
--- a/tools/ui/src/lib/utils/haptics.ts
+++ b/tools/ui/src/lib/utils/haptics.ts
@@ -1,6 +1,4 @@
-import { HAPTIC_TAP_MS } from '$lib/constants';
-
-const COARSE_POINTER_QUERY = '(pointer: coarse)';
+import { HAPTIC_TAP_MS, MEDIA_QUERIES } from '$lib/constants';
/**
* A light tap of the phone's haptic engine, the confirmation a native app gives by feel.
@@ -11,7 +9,8 @@ const COARSE_POINTER_QUERY = '(pointer: coarse)';
* iOS stays silent. A screen without touch gets nothing.
*/
export function hapticTap(): void {
- if (typeof window === 'undefined' || !window.matchMedia(COARSE_POINTER_QUERY).matches) return;
+ if (typeof window === 'undefined' || !window.matchMedia(MEDIA_QUERIES.COARSE_POINTER).matches)
+ return;
if (typeof navigator.vibrate === 'function') {
navigator.vibrate(HAPTIC_TAP_MS);
diff --git a/tools/ui/src/routes/+layout.svelte b/tools/ui/src/routes/+layout.svelte
index 03a46b5a6..1b1faf464 100644
--- a/tools/ui/src/routes/+layout.svelte
+++ b/tools/ui/src/routes/+layout.svelte
@@ -9,6 +9,7 @@
import * as Tooltip from '$lib/components/ui/tooltip';
import {
BOX_BORDER,
+ CHAT_FORM_CHROME_FADE_MS,
FAVICON_PATHS,
FAVICON_SELECTORS,
HEADERS,
@@ -39,6 +40,7 @@
import { ModeWatcher } from 'mode-watcher';
import { untrack } from 'svelte';
import { onMount } from 'svelte';
+ import { fade } from 'svelte/transition';
import { Toaster } from 'svelte-sonner';
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
@@ -357,9 +359,11 @@
onpointermove={sidebarReveal.handlePointerMove}
onpointerup={sidebarReveal.handlePointerUp}
>
- <!-- the installed app's status bar sits over the page: what scrolls under it fades out -->
- {#if deviceStore.isMobile}
+ <!-- the installed app's status bar sits over the page: what scrolls under it fades out;
+ the expanded chat form keeps clear of the bar itself -->
+ {#if deviceStore.isMobile && !uiStore.isComposerExpanded}
<div
+ transition:fade={{ duration: CHAT_FORM_CHROME_FADE_MS }}
aria-hidden="true"
class="status-bar-fade sidebar-reveal-pin-top pointer-events-none fixed inset-x-0 top-0 z-20"
></div>
@@ -384,16 +388,19 @@
></div>
{/if}
- <!-- the phone sidebar sits under the page, so its toggle rides on the page -->
- {#if deviceStore.isMobile && !page.url.hash.includes(ROUTES.SEARCH)}
- <ActionIcon
- ariaLabel={uiStore.isSidebarExpanded ? 'Collapse navigation' : 'Expand navigation'}
- class="sidebar-reveal-pin-top fixed top-(--corner-control-top) left-4 z-20 transition-colors h-10 w-10 rounded-full bg-muted/60 border border-border/20 shadow-sm text-accent-foreground"
- icon={MenuIcon}
- iconSize="size-6"
- onclick={toggleSidebar}
- size="lg"
- />
+ <!-- the phone sidebar sits under the page, so its toggle rides on the page; it steps
+ aside while the expanded chat form covers the screen -->
+ {#if deviceStore.isMobile && !page.url.hash.includes(ROUTES.SEARCH) && !uiStore.isComposerExpanded}
+ <div transition:fade={{ duration: CHAT_FORM_CHROME_FADE_MS }}>
+ <ActionIcon
+ ariaLabel={uiStore.isSidebarExpanded ? 'Collapse navigation' : 'Expand navigation'}
+ class="sidebar-reveal-pin-top fixed top-(--corner-control-top) left-4 z-20 transition-colors h-10 w-10 rounded-full bg-muted/60 border border-border/20 shadow-sm text-accent-foreground"
+ icon={MenuIcon}
+ iconSize="size-6"
+ onclick={toggleSidebar}
+ size="lg"
+ />
+ </div>
{/if}
{@render children?.()}
diff --git a/tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts b/tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts
deleted file mode 100644
index 6e62ab496..000000000
--- a/tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-// Guards the Enter-key contract of the chat form against the
-// code-block flow: while the caret sits inside a code block - ```
-// or ~~~ fenced, closed or still OPEN while the user is typing one -
-// plain Enter adds a line instead of submitting the message.
-// The form gates submit; the editor then adds the line itself.
-
-import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
-import { EditorView } from '@codemirror/view';
-import { SETTINGS_KEYS } from '$lib/constants';
-import { settingsStore } from '$lib/stores/settings/index.svelte';
-import { tick } from 'svelte';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { userEvent } from 'vitest/browser';
-import { render } from 'vitest-browser-svelte';
-
-function editorIn(container: HTMLElement): EditorView {
- const el = container.querySelector<HTMLElement>('.cm-content');
- const view = el && EditorView.findFromDOM(el);
-
- if (!view) throw new Error('editor not rendered');
-
- return view;
-}
-
-describe('ChatForm Enter in code blocks', () => {
- beforeEach(() => {
- settingsStore.updateConfig(SETTINGS_KEYS.SEND_ON_ENTER, true);
- });
-
- it('adds a line after a still-open fence instead of submitting', async () => {
- const onSubmit = vi.fn();
- const { container } = render(ChatFormTestWrapper, { onSubmit });
-
- await tick();
-
- const editor = editorIn(container);
-
- await userEvent.click(editor.contentDOM);
- await userEvent.keyboard('```');
- await tick();
-
- await userEvent.keyboard('{Enter}');
- await tick();
-
- expect(onSubmit).not.toHaveBeenCalled();
- expect(editor.state.doc.toString()).toBe('```\n');
- });
-
- it('keeps adding lines while the block stays open', async () => {
- const onSubmit = vi.fn();
- const { container } = render(ChatFormTestWrapper, { onSubmit });
-
- await tick();
-
- const editor = editorIn(container);
-
- await userEvent.click(editor.contentDOM);
- await userEvent.keyboard('```js');
- await tick();
-
- await userEvent.keyboard('{Enter}');
- await userEvent.keyboard('const a = 1;');
- await userEvent.keyboard('{Enter}');
- await tick();
-
- expect(onSubmit).not.toHaveBeenCalled();
- expect(editor.state.doc.toString()).toBe('```js\nconst a = 1;\n');
- });
-
- it('adds a line in a tilde-fenced block', async () => {
- const onSubmit = vi.fn();
- const { container } = render(ChatFormTestWrapper, { onSubmit });
-
- await tick();
-
- const editor = editorIn(container);
-
- await userEvent.click(editor.contentDOM);
- await userEvent.keyboard('~~~');
- await tick();
-
- await userEvent.keyboard('{Enter}');
- await tick();
-
- expect(onSubmit).not.toHaveBeenCalled();
- expect(editor.state.doc.toString()).toBe('~~~\n');
- });
-
- it('submits right after a closing fence', async () => {
- const onSubmit = vi.fn();
- const { container } = render(ChatFormTestWrapper, { onSubmit });
-
- await tick();
-
- const editor = editorIn(container);
-
- await userEvent.click(editor.contentDOM);
- editor.dispatch({
- changes: { from: 0, insert: '```\ncode\n```' },
- selection: { anchor: '```\ncode\n```'.length }
- });
- await tick();
-
- await userEvent.keyboard('{Enter}');
- await tick();
-
- expect(onSubmit).toHaveBeenCalledTimes(1);
- });
-
- it('submits when the caret is before the opening fence', async () => {
- const onSubmit = vi.fn();
- const { container } = render(ChatFormTestWrapper, { onSubmit });
-
- await tick();
-
- const editor = editorIn(container);
-
- await userEvent.click(editor.contentDOM);
- await userEvent.keyboard('```');
- await tick();
-
- editor.dispatch({ selection: { anchor: 0 } });
-
- await userEvent.keyboard('{Enter}');
- await tick();
-
- expect(onSubmit).toHaveBeenCalledTimes(1);
- });
-
- it('submits on Enter outside a code block', async () => {
- const onSubmit = vi.fn();
- const { container } = render(ChatFormTestWrapper, { onSubmit });
-
- await tick();
-
- const editor = editorIn(container);
-
- await userEvent.click(editor.contentDOM);
- await userEvent.keyboard('hello');
- await tick();
-
- await userEvent.keyboard('{Enter}');
- await tick();
-
- expect(onSubmit).toHaveBeenCalledTimes(1);
- });
-
- it('submits on Ctrl+Enter even inside a code block', async () => {
- const onSubmit = vi.fn();
- const { container } = render(ChatFormTestWrapper, { onSubmit });
-
- await tick();
-
- const editor = editorIn(container);
-
- await userEvent.click(editor.contentDOM);
- await userEvent.keyboard('```');
- await tick();
-
- await userEvent.keyboard('{Control>}{Enter}{/Control}');
- await tick();
-
- expect(onSubmit).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/tools/ui/tests/client/chat-form-enter.svelte.test.ts b/tools/ui/tests/client/chat-form-enter.svelte.test.ts
new file mode 100644
index 000000000..412d00425
--- /dev/null
+++ b/tools/ui/tests/client/chat-form-enter.svelte.test.ts
@@ -0,0 +1,109 @@
+// Guards the Enter-key contract of the chat form: Enter sends, and Shift+Enter or
+// Ctrl/Cmd+Enter adds a line (continuing lists), in the small form and in the expanded
+// editor alike. With sending on Enter turned off, or on a touch screen, the two swap.
+
+import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
+import { EditorView } from '@codemirror/view';
+import { SETTINGS_KEYS } from '$lib/constants';
+import { deviceStore } from '$lib/stores';
+import { settingsStore } from '$lib/stores/settings/index.svelte';
+import { tick } from 'svelte';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { userEvent } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
+
+async function renderForm({ expanded = false } = {}) {
+ const onSubmit = vi.fn();
+ const { container } = render(ChatFormTestWrapper, { expandable: true, onSubmit });
+
+ await tick();
+
+ if (expanded) {
+ await userEvent.click(
+ container.querySelector<HTMLElement>('button[aria-label="Expand editor"]')!
+ );
+ await tick();
+ }
+
+ const content = container.querySelector<HTMLElement>('.cm-content');
+ const editor = content && EditorView.findFromDOM(content);
+
+ if (!editor) throw new Error('editor not rendered');
+
+ await userEvent.click(editor.contentDOM);
+
+ return { editor, onSubmit };
+}
+
+describe('ChatForm Enter', () => {
+ afterEach(() => {
+ settingsStore.updateConfig(SETTINGS_KEYS.SEND_ON_ENTER, true);
+ vi.restoreAllMocks();
+ });
+
+ it.each([false, true])('sends on Enter (expanded: %s)', async (expanded) => {
+ const { editor, onSubmit } = await renderForm({ expanded });
+
+ await userEvent.keyboard('hello{Enter}');
+ await tick();
+
+ expect(onSubmit).toHaveBeenCalledOnce();
+ expect(editor.state.doc.toString()).toBe('hello');
+ });
+
+ it('sends on Enter inside a code block too', async () => {
+ const { onSubmit } = await renderForm();
+
+ await userEvent.keyboard('```js{Enter}');
+ await tick();
+
+ expect(onSubmit).toHaveBeenCalledOnce();
+ });
+
+ it.each(['Shift', 'Control', 'Meta'])('adds a line on %s+Enter', async (modifier) => {
+ const { editor, onSubmit } = await renderForm();
+
+ await userEvent.keyboard(`hello{${modifier}>}{Enter}{/${modifier}}world`);
+ await tick();
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(editor.state.doc.toString()).toBe('hello\nworld');
+ });
+
+ it('continues a list on Shift+Enter', async () => {
+ const { editor, onSubmit } = await renderForm({ expanded: true });
+
+ await userEvent.keyboard('- one{Shift>}{Enter}{/Shift}two');
+ await tick();
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(editor.state.doc.toString()).toBe('- one\n- two');
+ });
+
+ it('adds a line on Enter with a touch screen', async () => {
+ vi.spyOn(deviceStore, 'isTouch', 'get').mockReturnValue(true);
+
+ const { editor, onSubmit } = await renderForm({ expanded: true });
+
+ await userEvent.keyboard('hello{Enter}world');
+ await tick();
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(editor.state.doc.toString()).toBe('hello\nworld');
+ });
+
+ it('swaps the two with sending on Enter turned off', async () => {
+ settingsStore.updateConfig(SETTINGS_KEYS.SEND_ON_ENTER, false);
+
+ const { editor, onSubmit } = await renderForm();
+
+ await userEvent.keyboard('hello{Enter}world');
+ await tick();
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(editor.state.doc.toString()).toBe('hello\nworld');
+
+ await userEvent.keyboard('{Control>}{Enter}{/Control}');
+ await tick();
+ expect(onSubmit).toHaveBeenCalledOnce();
+ });
+});
diff --git a/tools/ui/tests/client/chat-form-expansion.svelte.test.ts b/tools/ui/tests/client/chat-form-expansion.svelte.test.ts
new file mode 100644
index 000000000..120587c1b
--- /dev/null
+++ b/tools/ui/tests/client/chat-form-expansion.svelte.test.ts
@@ -0,0 +1,170 @@
+// Guards when the main chat form expands into its large editor and collapses back:
+// on its own once a message asks for it, by hand, and back once it is cleared.
+
+import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
+import { EditorView } from '@codemirror/view';
+import { conversationsStore } from '$lib/stores';
+import { tick } from 'svelte';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { page, userEvent } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
+
+const PHONE = { height: 844, width: 390 };
+const DESKTOP = { height: 900, width: 1280 };
+
+async function renderForm(initialValue = '') {
+ const { component, container } = render(ChatFormTestWrapper, { expandable: true, initialValue });
+
+ await tick();
+
+ const content = container.querySelector<HTMLElement>('.cm-content');
+ const editor = content && EditorView.findFromDOM(content);
+
+ if (!editor) throw new Error('editor not rendered');
+
+ const write = async (text: string) => {
+ editor.dispatch({ changes: { from: 0, insert: text, to: editor.state.doc.length } });
+ await tick();
+ };
+ const isExpanded = () => !!container.querySelector('.chat-form-input.expanded');
+ // on a phone-sized screen the sheet ignores the key while it moves
+ const sheetMoves = () =>
+ container
+ .querySelector('[data-slot="input-area"]')!
+ .getAnimations()
+ .filter((animation) => !(animation instanceof CSSTransition)).length;
+ const toggle = async () => {
+ await expect.poll(sheetMoves).toBe(0);
+ await userEvent.click(
+ container.querySelector<HTMLElement>(
+ `button[aria-label="${isExpanded() ? 'Collapse' : 'Expand'} editor"]`
+ )!
+ );
+ await expect.poll(sheetMoves).toBe(0);
+ };
+
+ return { component, container, editor, isExpanded, toggle, write };
+}
+
+describe('ChatForm expansion', () => {
+ beforeEach(async () => {
+ await page.viewport(DESKTOP.width, DESKTOP.height);
+ });
+
+ afterEach(() => {
+ conversationsStore.activeConversation = null;
+ });
+
+ it('stays small for a line that only starts like markdown', async () => {
+ const { isExpanded, write } = await renderForm();
+
+ for (const text of ['*', '-5 degrees', '# Title', '1.5x faster']) {
+ await write(text);
+ expect(isExpanded(), text).toBe(false);
+ }
+ });
+
+ it('expands on a finished markdown line or a fourth line', async () => {
+ const { isExpanded, write } = await renderForm();
+
+ await write('# Title\n');
+ expect(isExpanded()).toBe(true);
+
+ await write('');
+ expect(isExpanded()).toBe(false);
+
+ await write('one\ntwo\nthree\nfour');
+ expect(isExpanded()).toBe(true);
+ });
+
+ it('stays expanded while the message has text', async () => {
+ const { isExpanded, write } = await renderForm();
+
+ await write('one\ntwo\nthree\nfour');
+ await write('one');
+
+ expect(isExpanded()).toBe(true);
+ });
+
+ it('keeps a collapse by hand until the message is cleared', async () => {
+ const { isExpanded, toggle, write } = await renderForm();
+
+ await write('# Title\n');
+ await toggle();
+ await tick();
+ expect(isExpanded()).toBe(false);
+
+ await write('# Title\n\n- item\n');
+ expect(isExpanded()).toBe(false);
+
+ await write('');
+ await write('# Title\n');
+ expect(isExpanded()).toBe(true);
+ });
+
+ it('leaves a restored draft in the small form until it is written in', async () => {
+ const { isExpanded, write } = await renderForm('# Title\n\n- item\n');
+
+ expect(isExpanded()).toBe(false);
+
+ await write('# Title\n\n- item\n- another\n');
+ expect(isExpanded()).toBe(true);
+ });
+
+ it('expands a message put back after a failed send', async () => {
+ const { component, isExpanded } = await renderForm('one\ntwo\nthree\nfour');
+
+ expect(isExpanded()).toBe(false);
+
+ component.expandForMessage();
+ await tick();
+
+ expect(isExpanded()).toBe(true);
+ });
+
+ it('expands on a phone only by hand', async () => {
+ await page.viewport(PHONE.width, PHONE.height);
+
+ const { isExpanded, toggle, write } = await renderForm();
+
+ await write('# Title\n\n- item\n- another\n');
+ expect(isExpanded()).toBe(false);
+
+ await toggle();
+ expect(isExpanded()).toBe(true);
+ });
+
+ it('starts collapsed in another conversation', async () => {
+ const { isExpanded, write } = await renderForm();
+
+ await write('# Title\n');
+ expect(isExpanded()).toBe(true);
+
+ conversationsStore.activeConversation = { id: 'other' } as DatabaseConversation;
+ await tick();
+
+ expect(isExpanded()).toBe(false);
+ });
+
+ it('scrolls a long message inside the editor', async () => {
+ const draft = Array.from({ length: 200 }, (_, i) => `line ${i + 1}`).join('\n');
+ const { container } = await renderForm(draft);
+ const input = container.querySelector<HTMLElement>('.chat-form-input')!;
+ const scroller = container.querySelector<HTMLElement>('.cm-scroller')!;
+
+ // stands in for the stylesheet's --max-message-height, which the tests do not load
+ input.style.maxHeight = '200px';
+
+ await expect.poll(() => scroller.clientHeight).toBeLessThanOrEqual(200);
+ expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight);
+ });
+
+ it('mirrors the height of the text onto the input', async () => {
+ const { container, editor, write } = await renderForm();
+ const input = container.querySelector<HTMLElement>('.chat-form-input')!;
+
+ await write('one\ntwo');
+ await expect.poll(() => input.style.height).toBe(`${editor.contentDOM.offsetHeight}px`);
+ expect(editor.contentDOM.offsetHeight).toBeGreaterThan(40);
+ });
+});
diff --git a/tools/ui/tests/client/chat-form-pickers-touch.svelte.test.ts b/tools/ui/tests/client/chat-form-pickers-touch.svelte.test.ts
new file mode 100644
index 000000000..79ce79258
--- /dev/null
+++ b/tools/ui/tests/client/chat-form-pickers-touch.svelte.test.ts
@@ -0,0 +1,48 @@
+// Guards that typing `/` in the chat form opens the command picker with a mouse and
+// nothing with a finger, where the pickers are off until they can be placed reliably
+// around the on-screen keyboard.
+
+import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
+import { EditorView } from '@codemirror/view';
+import { deviceStore } from '$lib/stores';
+import { tick } from 'svelte';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+async function typeSlash() {
+ const { container } = render(ChatFormTestWrapper);
+
+ await tick();
+
+ const content = container.querySelector<HTMLElement>('.cm-content');
+ const editor = content && EditorView.findFromDOM(content);
+
+ if (!editor) throw new Error('editor not rendered');
+
+ editor.dispatch({ changes: { from: 0, insert: '/' }, selection: { anchor: 1 } });
+ await tick();
+}
+
+const commandPicker = () => document.querySelector('[data-slot="popover-content"]');
+
+describe('ChatForm typed pickers', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('open the command picker with a mouse', async () => {
+ vi.spyOn(deviceStore, 'isTouch', 'get').mockReturnValue(false);
+ await typeSlash();
+
+ await expect.poll(commandPicker).not.toBeNull();
+ });
+
+ it('open nothing with a finger', async () => {
+ vi.spyOn(deviceStore, 'isTouch', 'get').mockReturnValue(true);
+ await typeSlash();
+
+ // long enough for the popover to have mounted, as it does with a mouse
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ expect(commandPicker()).toBeNull();
+ });
+});
diff --git a/tools/ui/tests/client/chat-form-single-row.svelte.test.ts b/tools/ui/tests/client/chat-form-single-row.svelte.test.ts
new file mode 100644
index 000000000..ed8cf499e
--- /dev/null
+++ b/tools/ui/tests/client/chat-form-single-row.svelte.test.ts
@@ -0,0 +1,81 @@
+// Guards when the main chat form keeps a one-line message on the actions' row, and that
+// a second line, or a line that wraps, takes the two rows without flipping back.
+
+import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
+import { EditorView } from '@codemirror/view';
+import { tick } from 'svelte';
+import { afterEach, describe, expect, it } from 'vitest';
+import { page } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
+
+const PHONE = { height: 844, width: 390 };
+const DESKTOP = { height: 900, width: 1280 };
+
+async function renderForm() {
+ const { container } = render(ChatFormTestWrapper, { expandable: true, singleRow: true });
+
+ await tick();
+
+ const content = container.querySelector<HTMLElement>('.cm-content');
+ const editor = content && EditorView.findFromDOM(content);
+
+ if (!editor) throw new Error('editor not rendered');
+
+ const card = container.querySelector<HTMLElement>('[data-slot="input-area"]')!;
+ const isSingleRow = () => card.hasAttribute('data-single-row');
+ const write = async (text: string) => {
+ editor.dispatch({ changes: { from: 0, insert: text, to: editor.state.doc.length } });
+ await tick();
+ // the text's height arrives with the next layout
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ };
+
+ return { isSingleRow, write };
+}
+
+describe('ChatForm single row', () => {
+ afterEach(async () => {
+ await page.viewport(DESKTOP.width, DESKTOP.height);
+ });
+
+ it('keeps one line on the actions row, and a second line below them', async () => {
+ await page.viewport(DESKTOP.width, DESKTOP.height);
+
+ const { isSingleRow, write } = await renderForm();
+
+ expect(isSingleRow()).toBe(true);
+
+ await write('What is the meaning of life?');
+ expect(isSingleRow()).toBe(true);
+
+ await write('What is the meaning of life?\nIn one sentence.');
+ expect(isSingleRow()).toBe(false);
+
+ await write('What is the meaning of life?');
+ expect(isSingleRow()).toBe(true);
+ });
+
+ it('leaves a line that wrapped on two rows until it is shorter than where it wrapped', async () => {
+ await page.viewport(DESKTOP.width, DESKTOP.height);
+
+ const { isSingleRow, write } = await renderForm();
+ const long = 'a long question '.repeat(40).trim();
+
+ await write(long);
+ expect(isSingleRow()).toBe(false);
+
+ await write(long.slice(0, -1));
+ expect(isSingleRow()).toBe(false);
+
+ await write('short');
+ expect(isSingleRow()).toBe(true);
+ });
+
+ it('keeps the two rows on a phone', async () => {
+ await page.viewport(PHONE.width, PHONE.height);
+
+ const { isSingleRow } = await renderForm();
+
+ expect(isSingleRow()).toBe(false);
+ });
+});
diff --git a/tools/ui/tests/client/chat-screen-form-single-row-setting.svelte.test.ts b/tools/ui/tests/client/chat-screen-form-single-row-setting.svelte.test.ts
new file mode 100644
index 000000000..8502774d6
--- /dev/null
+++ b/tools/ui/tests/client/chat-screen-form-single-row-setting.svelte.test.ts
@@ -0,0 +1,33 @@
+// Guards the Display setting behind the single-row chat form: on by default, and off
+// it leaves a one-line message on the two rows.
+
+import ChatScreenFormTestWrapper from './components/ChatScreenFormTestWrapper.svelte';
+import { SETTINGS_KEYS } from '$lib/constants';
+import { settingsStore } from '$lib/stores/settings/index.svelte';
+import { tick } from 'svelte';
+import { afterEach, describe, expect, it } from 'vitest';
+import { page } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
+
+async function isSingleRow() {
+ const { container } = render(ChatScreenFormTestWrapper);
+
+ await tick();
+
+ return container.querySelector('[data-slot="input-area"]')!.hasAttribute('data-single-row');
+}
+
+describe('single-row chat form setting', () => {
+ afterEach(() => {
+ settingsStore.updateConfig(SETTINGS_KEYS.SINGLE_ROW_CHAT_FORM, true);
+ });
+
+ it('is on by default and can be turned off', async () => {
+ await page.viewport(1280, 900);
+
+ expect(await isSingleRow()).toBe(true);
+
+ settingsStore.updateConfig(SETTINGS_KEYS.SINGLE_ROW_CHAT_FORM, false);
+ expect(await isSingleRow()).toBe(false);
+ });
+});
diff --git a/tools/ui/tests/client/components/ChatFormPickersHarness.svelte b/tools/ui/tests/client/components/ChatFormPickersHarness.svelte
index 80f5080fe..9e1dda35e 100644
--- a/tools/ui/tests/client/components/ChatFormPickersHarness.svelte
+++ b/tools/ui/tests/client/components/ChatFormPickersHarness.svelte
@@ -25,6 +25,7 @@
openModelSelector: () => {
calls.push('openModelSelector');
},
+ opensOnInput: () => true,
setCaretOffset: (o) => {
caretOffset = o;
},
diff --git a/tools/ui/tests/client/components/ChatFormTestWrapper.svelte b/tools/ui/tests/client/components/ChatFormTestWrapper.svelte
index 7ec8bf7f8..02e168322 100644
--- a/tools/ui/tests/client/components/ChatFormTestWrapper.svelte
+++ b/tools/ui/tests/client/components/ChatFormTestWrapper.svelte
@@ -1,12 +1,26 @@
<script lang="ts">
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
+ import { untrack } from 'svelte';
- let { onSubmit }: { onSubmit?: () => void } = $props();
+ interface Props {
+ expandable?: boolean;
+ /** The message the form starts with, as a restored draft would be. */
+ initialValue?: string;
+ onSubmit?: () => void;
+ singleRow?: boolean;
+ }
- let value = $state('');
+ let { expandable = false, initialValue = '', onSubmit, singleRow = false }: Props = $props();
+
+ let value = $state(untrack(() => initialValue));
+ let form: ChatForm | undefined = $state();
+
+ export function expandForMessage() {
+ form?.expandForMessage();
+ }
</script>
<Tooltip.Provider>
- <ChatForm bind:value {onSubmit} />
+ <ChatForm bind:this={form} bind:value {expandable} {onSubmit} {singleRow} />
</Tooltip.Provider>
diff --git a/tools/ui/tests/client/components/ChatScreenFormTestWrapper.svelte b/tools/ui/tests/client/components/ChatScreenFormTestWrapper.svelte
new file mode 100644
index 000000000..3e780e80e
--- /dev/null
+++ b/tools/ui/tests/client/components/ChatScreenFormTestWrapper.svelte
@@ -0,0 +1,8 @@
+<script lang="ts">
+ import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+</script>
+
+<Tooltip.Provider>
+ <ChatScreenForm />
+</Tooltip.Provider>
diff --git a/tools/ui/tests/client/keyboard-inset.svelte.test.ts b/tools/ui/tests/client/keyboard-inset.svelte.test.ts
new file mode 100644
index 000000000..132cc203f
--- /dev/null
+++ b/tools/ui/tests/client/keyboard-inset.svelte.test.ts
@@ -0,0 +1,63 @@
+// Guards useKeyboardInset: the part of the screen an on-screen keyboard covers is
+// published as --keyboard-inset while active, and never while pinch-zoomed.
+
+import { useKeyboardInset } from '$lib/hooks/use-keyboard-inset.svelte';
+import { flushSync } from 'svelte';
+import { afterEach, describe, expect, it } from 'vitest';
+
+const realViewport = window.visualViewport;
+
+function mockViewport(covered: number, scale = 1) {
+ const viewport = Object.assign(new EventTarget(), {
+ height: window.innerHeight - covered,
+ offsetTop: 0,
+ scale
+ });
+
+ Object.defineProperty(window, 'visualViewport', { configurable: true, value: viewport });
+
+ return viewport;
+}
+
+function inset() {
+ return document.documentElement.style.getPropertyValue('--keyboard-inset');
+}
+
+afterEach(() => {
+ Object.defineProperty(window, 'visualViewport', { configurable: true, value: realViewport });
+});
+
+describe('useKeyboardInset', () => {
+ it('publishes the covered height and clears it on cleanup', () => {
+ const viewport = mockViewport(0);
+ const stop = $effect.root(() => useKeyboardInset(() => true));
+
+ flushSync();
+ expect(inset()).toBe('');
+
+ viewport.height = window.innerHeight - 300;
+ viewport.dispatchEvent(new Event('resize'));
+ expect(inset()).toBe('300px');
+
+ stop();
+ expect(inset()).toBe('');
+ });
+
+ it('reads nothing while zoomed or inactive', () => {
+ mockViewport(300, 2);
+
+ const stopZoomed = $effect.root(() => useKeyboardInset(() => true));
+
+ flushSync();
+ expect(inset()).toBe('');
+ stopZoomed();
+
+ mockViewport(300);
+
+ const stopInactive = $effect.root(() => useKeyboardInset(() => false));
+
+ flushSync();
+ expect(inset()).toBe('');
+ stopInactive();
+ });
+});
diff --git a/tools/ui/tests/client/stick-to-bottom.svelte.test.ts b/tools/ui/tests/client/stick-to-bottom.svelte.test.ts
new file mode 100644
index 000000000..f3c49e667
--- /dev/null
+++ b/tools/ui/tests/client/stick-to-bottom.svelte.test.ts
@@ -0,0 +1,89 @@
+// Guards the follow rules of StickToBottom: right after a press, growth of the
+// content stays put (the reader expanded something), while growth of an element
+// passed to followSizeOf - the chat form expanding on the reader's click - is
+// still followed, so the latest message stays in view above it.
+
+import { createStickToBottom } from '$lib/hooks/use-stick-to-bottom.svelte';
+import { afterEach, describe, expect, it } from 'vitest';
+
+let cleanup: (() => void)[] = [];
+
+afterEach(() => {
+ cleanup.forEach((fn) => fn());
+ cleanup = [];
+});
+
+function setup() {
+ const container = document.createElement('div');
+ const content = document.createElement('div');
+ const form = document.createElement('div');
+
+ container.style.cssText = 'height: 200px; overflow-y: auto;';
+ content.style.height = '600px';
+ form.style.height = '50px';
+ container.append(content, form);
+ document.body.append(container);
+
+ const stick = createStickToBottom();
+
+ stick.attach(container, content);
+ stick.scrollToBottom();
+ // as the container's scroll handler does, so a later move up reads as the reader's
+ stick.handleScroll();
+ cleanup.push(() => {
+ stick.detach();
+ container.remove();
+ });
+
+ return { container, content, form, stick };
+}
+
+function press(container: HTMLElement) {
+ container.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
+}
+
+async function resizeObserved() {
+ // ResizeObserver reports in the next rendering steps
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+}
+
+function distanceFromBottom(el: HTMLElement) {
+ return el.scrollHeight - el.clientHeight - el.scrollTop;
+}
+
+describe('StickToBottom', () => {
+ it('keeps the viewport when the content grows right after a press', async () => {
+ const { container, content } = setup();
+
+ press(container);
+ content.style.height = '900px';
+ await resizeObserved();
+
+ expect(distanceFromBottom(container)).toBeGreaterThan(200);
+ });
+
+ it('follows a followed element growing right after a press', async () => {
+ const { container, form, stick } = setup();
+
+ cleanup.push(stick.followSizeOf(form));
+ await resizeObserved();
+
+ press(container);
+ form.style.height = '350px';
+ await resizeObserved();
+
+ expect(distanceFromBottom(container)).toBeLessThanOrEqual(1);
+ });
+
+ it('leaves a reader who scrolled away where they are', async () => {
+ const { container, form, stick } = setup();
+
+ cleanup.push(stick.followSizeOf(form));
+ container.scrollTop = 0;
+ stick.handleScroll();
+ form.style.height = '350px';
+ await resizeObserved();
+
+ expect(container.scrollTop).toBe(0);
+ });
+});
diff --git a/tools/ui/tests/stories/ChatFormSingleRow.stories.svelte b/tools/ui/tests/stories/ChatFormSingleRow.stories.svelte
new file mode 100644
index 000000000..d2879c6fc
--- /dev/null
+++ b/tools/ui/tests/stories/ChatFormSingleRow.stories.svelte
@@ -0,0 +1,51 @@
+<script lang="ts" module>
+ import Preview from './fixtures/ChatFormSingleRowPreview.svelte';
+ import { defineMeta } from '@storybook/addon-svelte-csf';
+
+ const { Story } = defineMeta({
+ parameters: {
+ layout: 'centered'
+ },
+ title: 'Scrapbook/ChatForm single row'
+ });
+
+ const SHORT = 'What is the meaning of life?';
+ const TWO_LINES = 'What is the meaning of life?\nAnswer in one sentence.';
+
+ // the default is --chat-form-single-row-radius in app.css; the card is about 3.375rem tall
+ const RADII = [
+ ['2rem', 'a pill (2rem)'],
+ ['1.5rem', '1.5rem, like the two-row form on a desktop'],
+ ['1.25rem', '1.25rem (the default)'],
+ ['1rem', '1rem'],
+ ['0.75rem', '0.75rem']
+ ] as const;
+</script>
+
+<Story name="Empty">
+ <Preview />
+</Story>
+
+<Story name="Short text">
+ <Preview initial={SHORT} />
+</Story>
+
+<Story name="Two lines">
+ <Preview initial={TWO_LINES} />
+</Story>
+
+<Story name="Before: two rows">
+ <Preview today />
+</Story>
+
+<Story name="Radius variations">
+ <div class="flex flex-col gap-6">
+ {#each RADII as [radius, label] (radius)}
+ <div class="flex flex-col gap-1.5">
+ <span class="text-xs text-muted-foreground">{label}</span>
+
+ <Preview initial={SHORT} {radius} />
+ </div>
+ {/each}
+ </div>
+</Story>
diff --git a/tools/ui/tests/stories/fixtures/ChatFormSingleRowPreview.svelte b/tools/ui/tests/stories/fixtures/ChatFormSingleRowPreview.svelte
new file mode 100644
index 000000000..6cfc54726
--- /dev/null
+++ b/tools/ui/tests/stories/fixtures/ChatFormSingleRowPreview.svelte
@@ -0,0 +1,57 @@
+<script lang="ts" module>
+ import { ServerRole } from '$lib/enums';
+ import { ModelsService } from '$lib/services';
+ import { modelsStore, serverStore } from '$lib/stores';
+ import type { ModelOption } from '$lib/types/models';
+
+ const model = (id: string, name: string, orgName: string, params: string): ModelOption => ({
+ capabilities: [],
+ id,
+ model: id,
+ name,
+ parsedId: {
+ activatedParams: null,
+ modelName: name,
+ orgName,
+ params,
+ quantization: 'Q4_K_M',
+ raw: id,
+ sidecar: null,
+ tags: []
+ }
+ });
+
+ const MODELS = [
+ model('ggml-org/Qwen3-8B-GGUF:Q4_K_M', 'Qwen3-8B', 'ggml-org', '8B'),
+ model('unsloth/Llama-3.2-3B-GGUF:Q4_K_M', 'Llama-3.2-3B', 'unsloth', '3B')
+ ];
+
+ // a router with models to pick from, the first one selected: what the chat form shows
+ // with a server behind it. The selector fetches its list on mount, so it is served the same
+ serverStore.role = ServerRole.ROUTER;
+ ModelsService.list = async () => ({ data: MODELS.map(({ id }) => ({ id })) }) as never;
+ modelsStore.models = MODELS;
+ modelsStore.selectedModelId = MODELS[0].id;
+</script>
+
+<script lang="ts">
+ import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
+ import { untrack } from 'svelte';
+
+ interface Props {
+ initial?: string;
+ /** The single row's corner radius, any CSS length. */
+ radius?: string;
+ /** The chat form without the single row, as it was before. */
+ today?: boolean;
+ width?: string;
+ }
+
+ let { initial = '', radius, today = false, width = '52rem' }: Props = $props();
+
+ let value = $state(untrack(() => initial));
+</script>
+
+<div style:--chat-form-single-row-radius={radius} style:width>
+ <ChatForm bind:value singleRow={!today} />
+</div>
diff --git a/tools/ui/tests/unit/chat-form-expand.test.ts b/tools/ui/tests/unit/chat-form-expand.test.ts
new file mode 100644
index 000000000..01f389b5e
--- /dev/null
+++ b/tools/ui/tests/unit/chat-form-expand.test.ts
@@ -0,0 +1,70 @@
+import {
+ SHEET_AT_REST,
+ sheetFrameAt,
+ type SheetRects,
+ shiftSheetRects,
+ shouldExpandChatForm
+} from '$lib/components/app/chat/ChatForm/chat-form-expand';
+import { describe, expect, it } from 'vitest';
+
+describe('shouldExpandChatForm', () => {
+ it('ignores markdown markers on the line still being typed', () => {
+ for (const text of ['*', '*really*', '-', '-5 degrees', '#', '#hashtag', '> ', '1.', '2024.']) {
+ expect(shouldExpandChatForm(text), text).toBe(false);
+ }
+
+ expect(shouldExpandChatForm('# Title')).toBe(false);
+ expect(shouldExpandChatForm('plain text\n- item')).toBe(false);
+ });
+
+ it('expands on a markdown block in a finished line', () => {
+ expect(shouldExpandChatForm('# Title\n')).toBe(true);
+ expect(shouldExpandChatForm('- item\nmore')).toBe(true);
+ expect(shouldExpandChatForm('```js\nconst a = 1;')).toBe(true);
+ });
+
+ it('expands on several lines of plain text', () => {
+ expect(shouldExpandChatForm('one\ntwo\nthree')).toBe(false);
+ expect(shouldExpandChatForm('one\ntwo\nthree\nfour')).toBe(true);
+ });
+});
+
+describe('sheetFrameAt', () => {
+ // a phone screen, and the card floating above its bottom edge
+ const sheet: SheetRects = {
+ actionsTop: 780,
+ card: { height: 844, left: 0, right: 390, top: 0 },
+ padTop: 47
+ };
+ const card: SheetRects = {
+ actionsTop: 770,
+ card: { height: 104, left: 8, right: 382, top: 728 },
+ padTop: 0
+ };
+
+ it('draws the sheet as the collapsed card', () => {
+ const frame = sheetFrameAt(sheet, card);
+
+ // raised by the status bar's padding, which is clipped off
+ expect(frame.card.top).toBe('681px');
+ expect(frame.card.clipPath).toBe('inset(47px 8px 693px 8px round 2rem)');
+ // the actions land on the collapsed card's, whatever the card's own offset
+ expect(frame.actions.top).toBe('-691px');
+ });
+
+ it('leaves the sheet alone when it already is the card', () => {
+ const frame = sheetFrameAt(card, card);
+
+ expect(frame.card.top).toBe(SHEET_AT_REST.card.top);
+ expect(frame.actions.top).toBe(SHEET_AT_REST.actions.top);
+ expect(frame.card.clipPath).toBe('inset(0px 0px 0px 0px round 2rem)');
+ });
+
+ it('moves the rects with the viewport', () => {
+ const moved = shiftSheetRects(card, -30);
+
+ expect(moved.actionsTop).toBe(740);
+ expect(moved.card).toEqual({ ...card.card, top: 698 });
+ expect(moved.padTop).toBe(card.padTop);
+ });
+});
diff --git a/tools/ui/tests/unit/markdown-structure.test.ts b/tools/ui/tests/unit/markdown-structure.test.ts
new file mode 100644
index 000000000..c7c74b152
--- /dev/null
+++ b/tools/ui/tests/unit/markdown-structure.test.ts
@@ -0,0 +1,22 @@
+import { hasMarkdownStructure } from '$lib/components/app/content/MarkdownEditor/markdown-structure';
+import { describe, expect, it } from 'vitest';
+
+describe('hasMarkdownStructure', () => {
+ it('is false for plain paragraphs, inline markup included', () => {
+ expect(hasMarkdownStructure('')).toBe(false);
+ expect(hasMarkdownStructure('hello world')).toBe(false);
+ expect(hasMarkdownStructure('one\n\ntwo')).toBe(false);
+ expect(hasMarkdownStructure('some **bold**, `code` and [a](file:///tmp/a.md)')).toBe(false);
+ expect(hasMarkdownStructure('/model is broken')).toBe(false);
+ });
+
+ it('is true for any other block', () => {
+ expect(hasMarkdownStructure('# Title')).toBe(true);
+ expect(hasMarkdownStructure('text\n\n- item')).toBe(true);
+ expect(hasMarkdownStructure('1. first')).toBe(true);
+ expect(hasMarkdownStructure('> quote')).toBe(true);
+ expect(hasMarkdownStructure('```js\nconst a = 1;\n```')).toBe(true);
+ expect(hasMarkdownStructure('| a | b |\n| - | - |\n| 1 | 2 |')).toBe(true);
+ expect(hasMarkdownStructure('---')).toBe(true);
+ });
+});
diff --git a/tools/ui/tests/unit/markdown-syntax.test.ts b/tools/ui/tests/unit/markdown-syntax.test.ts
index e003b8edb..a2ba4a126 100644
--- a/tools/ui/tests/unit/markdown-syntax.test.ts
+++ b/tools/ui/tests/unit/markdown-syntax.test.ts
@@ -1,56 +1,13 @@
import { markdownLanguage } from '@codemirror/lang-markdown';
import { LanguageSupport } from '@codemirror/language';
import { EditorState } from '@codemirror/state';
-import {
- codeRanges,
- isInCodeBlock
-} from '$lib/components/app/content/MarkdownEditor/markdown-syntax';
+import { codeRanges } from '$lib/components/app/content/MarkdownEditor/markdown-syntax';
import { describe, expect, it } from 'vitest';
function stateOf(doc: string): EditorState {
return EditorState.create({ doc, extensions: new LanguageSupport(markdownLanguage) });
}
-function inCode(doc: string, pos = doc.length): boolean {
- return isInCodeBlock(stateOf(doc), pos);
-}
-
-describe('isInCodeBlock', () => {
- it('is false in plain text and inline code', () => {
- expect(inCode('hello world', 5)).toBe(false);
- expect(inCode('run `npm test` now', 10)).toBe(false);
- });
-
- it('is true right after an opening fence and in a still-open block', () => {
- expect(inCode('```')).toBe(true);
- expect(inCode('```js')).toBe(true);
- expect(inCode('```js\nconst a = 1;')).toBe(true);
- });
-
- it('knows tilde fences and indented code', () => {
- expect(inCode('~~~\ncode')).toBe(true);
- expect(inCode('text\n\n indented')).toBe(true);
- });
-
- it('is true inside a closed block and false after or before it', () => {
- const block = '```\ncode\n```';
-
- expect(inCode(block, 6)).toBe(true);
- expect(inCode(block, 0)).toBe(false);
- expect(inCode(block)).toBe(false);
- expect(inCode(`${block}\nafter`)).toBe(false);
- });
-
- it('tells two blocks apart', () => {
- const two = '```\na\n```\nmid\n```\nb\n```';
- const second = two.lastIndexOf('```\nb');
-
- expect(inCode(two, two.indexOf('mid') + 1)).toBe(false);
- expect(inCode(two, second + 5)).toBe(true);
- expect(inCode(two)).toBe(false);
- });
-});
-
describe('codeRanges', () => {
it('lists inline code and code blocks in range', () => {
const doc = 'a `b` c\n\n~~~\nd\n~~~';