diff --git a/README.md b/README.md
index f96b1a2..5796910 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ IDK, calm down
- [ ] delete conversations option
- [ ] conversation title generation
- [ ] kbd powered popover model picker
+- [ ] autosize
### Extra
diff --git a/src/app.css b/src/app.css
index 1304726..9cccaed 100644
--- a/src/app.css
+++ b/src/app.css
@@ -239,3 +239,168 @@
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari, and Opera */
}
+
+@layer components {
+ /* Modal is from DaisyUI */
+ .modal {
+ @apply pointer-events-none invisible fixed inset-0 m-0 grid h-full max-h-none w-full max-w-none items-center justify-items-center bg-transparent p-0 text-[inherit];
+ overflow-x: hidden;
+ transition:
+ translate 0.3s ease-out,
+ visibility 0.3s allow-discrete,
+ background-color 0.3s ease-out,
+ opacity 0.1s ease-out;
+ overflow-y: hidden;
+ overscroll-behavior: contain;
+ z-index: 999;
+ &::backdrop {
+ @apply hidden;
+ }
+
+ &.modal-open,
+ &[open],
+ &:target {
+ @apply pointer-events-auto visible opacity-100;
+ background-color: oklch(0% 0 0/ 0.4);
+ /* this cause glitch on Chrome */
+ /* transition:
+ translate 0.3s ease-out,
+ background-color 0.3s ease-out,
+ opacity 0.1s ease-out; */
+ .modal-box {
+ translate: 0 0;
+ scale: 1;
+ opacity: 1;
+ }
+ }
+ @starting-style {
+ &.modal-open,
+ &[open],
+ &:target {
+ @apply invisible opacity-0;
+ }
+ }
+ }
+
+ .modal-action {
+ @apply mt-6 flex justify-end gap-2;
+ }
+
+ .modal-toggle {
+ @apply fixed h-0 w-0 appearance-none opacity-0;
+
+ &:checked + .modal {
+ @apply pointer-events-auto visible opacity-100;
+ background-color: oklch(0% 0 0/ 0.4);
+ .modal-box {
+ translate: 0 0;
+ scale: 1;
+ opacity: 1;
+ }
+ }
+ @starting-style {
+ &:checked + .modal {
+ @apply invisible opacity-0;
+ }
+ }
+ }
+
+ .modal-backdrop {
+ @apply col-start-1 row-start-1 grid self-stretch justify-self-stretch text-transparent;
+ z-index: -1;
+
+ button {
+ @apply cursor-pointer;
+ }
+ }
+
+ .modal-box {
+ @apply bg-background border-border col-start-1 row-start-1 max-h-screen w-11/12 max-w-[32rem] p-6;
+ transition:
+ translate 0.3s ease-out,
+ scale 0.3s ease-out,
+ opacity 0.2s ease-out 0.05s,
+ box-shadow 0.3s ease-out;
+
+ border-radius: var(--radius);
+
+ scale: 95%;
+ opacity: 0;
+ box-shadow: oklch(0% 0 0/ 0.25) 0px 25px 50px -12px;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ }
+
+ .modal-top {
+ @apply place-items-start;
+
+ :where(.modal-box) {
+ @apply h-auto w-full max-w-none;
+ max-height: calc(100vh - 5em);
+ translate: 0 -100%;
+ scale: 1;
+ --modal-tl: 0;
+ --modal-tr: 0;
+ --modal-bl: var(--radius-box);
+ --modal-br: var(--radius-box);
+ }
+ }
+
+ .modal-middle {
+ @apply place-items-center;
+
+ :where(.modal-box) {
+ @apply h-auto w-11/12 max-w-[32rem];
+ max-height: calc(100vh - 5em);
+ translate: 0 2%;
+ scale: 98%;
+ --modal-tl: var(--radius-box);
+ --modal-tr: var(--radius-box);
+ --modal-bl: var(--radius-box);
+ --modal-br: var(--radius-box);
+ }
+ }
+
+ .modal-bottom {
+ @apply place-items-end;
+
+ :where(.modal-box) {
+ @apply h-auto w-full max-w-none;
+ max-height: calc(100vh - 5em);
+ translate: 0 100%;
+ scale: 1;
+ --modal-tl: var(--radius-box);
+ --modal-tr: var(--radius-box);
+ --modal-bl: 0;
+ --modal-br: 0;
+ }
+ }
+
+ .modal-start {
+ @apply place-items-start;
+
+ :where(.modal-box) {
+ @apply h-screen max-h-none w-auto max-w-none;
+ translate: -100% 0;
+ scale: 1;
+ --modal-tl: 0;
+ --modal-tr: var(--radius-box);
+ --modal-bl: 0;
+ --modal-br: var(--radius-box);
+ }
+ }
+
+ .modal-end {
+ @apply place-items-end;
+
+ :where(.modal-box) {
+ @apply h-screen max-h-none w-auto max-w-none;
+ translate: 100% 0;
+ scale: 1;
+ --modal-tl: var(--radius-box);
+ --modal-tr: 0;
+ --modal-bl: var(--radius-box);
+ --modal-br: 0;
+ }
+ }
+}
diff --git a/src/lib/attachments/click-outside.svelte.ts b/src/lib/attachments/click-outside.svelte.ts
new file mode 100644
index 0000000..409c81e
--- /dev/null
+++ b/src/lib/attachments/click-outside.svelte.ts
@@ -0,0 +1,48 @@
+import type { Attachment } from 'svelte/attachments';
+
+export function clickOutside(callback: () => void): Attachment {
+ return (node) => {
+ function handleClick(event: MouseEvent) {
+ if (window.getSelection()?.toString()) {
+ // Don't close if text is selected
+ return;
+ }
+
+ // For dialog elements, check if click was on the backdrop
+ if (node instanceof HTMLDialogElement) {
+ const rect = node.getBoundingClientRect();
+ const isInDialog =
+ event.clientX >= rect.left &&
+ event.clientX <= rect.right &&
+ event.clientY >= rect.top &&
+ event.clientY <= rect.bottom;
+
+ if (!isInDialog) {
+ callback();
+ return;
+ }
+ }
+
+ // For non-dialog elements, use the standard contains check
+ if (!node.contains(event.target as Node) && !event.defaultPrevented) {
+ callback();
+ }
+ }
+
+ // For dialogs, listen on the element itself
+ if (node instanceof HTMLDialogElement) {
+ node.addEventListener('click', handleClick);
+ } else {
+ // For other elements, listen on the document
+ document.addEventListener('click', handleClick, true);
+ }
+
+ return () => {
+ if (node instanceof HTMLDialogElement) {
+ node.removeEventListener('click', handleClick);
+ } else {
+ document.removeEventListener('click', handleClick, true);
+ }
+ };
+ };
+}
diff --git a/src/lib/backend/convex/messages.ts b/src/lib/backend/convex/messages.ts
index 2f110a9..102b362 100644
--- a/src/lib/backend/convex/messages.ts
+++ b/src/lib/backend/convex/messages.ts
@@ -2,7 +2,7 @@ import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
import { api } from './_generated/api';
import { messageRoleValidator, providerValidator } from './schema';
-import type { Id } from './_generated/dataModel';
+import { type Id } from './_generated/dataModel';
export const getAllFromConversation = query({
args: {
diff --git a/src/lib/backend/convex/user_rules.ts b/src/lib/backend/convex/user_rules.ts
index 503a477..07778eb 100644
--- a/src/lib/backend/convex/user_rules.ts
+++ b/src/lib/backend/convex/user_rules.ts
@@ -2,7 +2,7 @@ import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
import { internal } from './_generated/api';
import { ruleAttachValidator } from './schema';
-import type { Doc } from './_generated/dataModel';
+import { type Doc } from './_generated/dataModel';
export const create = mutation({
args: {
diff --git a/src/lib/components/ui/modal/global-modal.svelte b/src/lib/components/ui/modal/global-modal.svelte
new file mode 100644
index 0000000..c9b92d0
--- /dev/null
+++ b/src/lib/components/ui/modal/global-modal.svelte
@@ -0,0 +1,56 @@
+
+
+
+
+ open,
+ (v) => {
+ if (v) return;
+ open = false;
+ setTimeout(() => (modalArgs = null), 200);
+ resolve?.(null);
+ }
+ }
+>
+ {modalArgs?.title}
+ {modalArgs?.description}
+ {#if modalArgs?.actions}
+
+ {#each Object.entries(modalArgs.actions) as [action, variant] (action)}
+
+ {/each}
+
+ {/if}
+
diff --git a/src/lib/components/ui/modal/index.ts b/src/lib/components/ui/modal/index.ts
new file mode 100644
index 0000000..5885b85
--- /dev/null
+++ b/src/lib/components/ui/modal/index.ts
@@ -0,0 +1,3 @@
+import Modal from './modal.svelte';
+
+export { Modal };
diff --git a/src/lib/components/ui/modal/modal.svelte b/src/lib/components/ui/modal/modal.svelte
new file mode 100644
index 0000000..2e86c73
--- /dev/null
+++ b/src/lib/components/ui/modal/modal.svelte
@@ -0,0 +1,32 @@
+
+
+
diff --git a/src/lib/components/ui/textarea/textarea.svelte b/src/lib/components/ui/textarea/textarea.svelte
index b0185ed..24352c6 100644
--- a/src/lib/components/ui/textarea/textarea.svelte
+++ b/src/lib/components/ui/textarea/textarea.svelte
@@ -1,8 +1,14 @@
diff --git a/src/lib/components/ui/tooltip.svelte b/src/lib/components/ui/tooltip.svelte
new file mode 100644
index 0000000..32da1d4
--- /dev/null
+++ b/src/lib/components/ui/tooltip.svelte
@@ -0,0 +1,71 @@
+
+
+{@render trigger(tooltip)}
+
+
+
+
{@render children()}
+
+
+
diff --git a/src/lib/spells/textarea-autosize.svelte.ts b/src/lib/spells/textarea-autosize.svelte.ts
new file mode 100644
index 0000000..4df4767
--- /dev/null
+++ b/src/lib/spells/textarea-autosize.svelte.ts
@@ -0,0 +1,182 @@
+import { useResizeObserver, watch, extract } from 'runed';
+import { onDestroy } from 'svelte';
+import type { Attachment } from 'svelte/attachments';
+import { on } from 'svelte/events';
+
+export interface TextareaAutosizeOptions {
+ /** Function called when the textarea size changes. */
+ onResize?: () => void;
+ /**
+ * Specify the style property that will be used to manipulate height. Can be `height | minHeight`.
+ * @default `height`
+ **/
+ styleProp?: 'height' | 'minHeight';
+ /**
+ * Maximum height of the textarea before enabling scrolling.
+ * @default `undefined` (no maximum)
+ */
+ maxHeight?: number;
+}
+
+export class TextareaAutosize {
+ #options: TextareaAutosizeOptions;
+ #resizeTimeout: number | null = null;
+ #hiddenTextarea: HTMLTextAreaElement | null = null;
+
+ element: HTMLTextAreaElement | undefined;
+ #input = '';
+ styleProp = $derived.by(() => extract(this.#options.styleProp, 'height'));
+ maxHeight = $derived.by(() => extract(this.#options.maxHeight, undefined));
+ textareaHeight = $state(0);
+ textareaOldWidth = $state(0);
+
+ constructor(options: TextareaAutosizeOptions = {}) {
+ this.#options = options;
+
+ // Create hidden textarea for measurements
+ this.#createHiddenTextarea();
+
+ watch(
+ () => this.textareaHeight,
+ () => options?.onResize?.()
+ );
+
+ useResizeObserver(
+ () => this.element,
+ ([entry]) => {
+ if (!entry) return;
+ const { contentRect } = entry;
+ if (this.textareaOldWidth === contentRect.width) return;
+
+ this.textareaOldWidth = contentRect.width;
+ this.triggerResize();
+ }
+ );
+
+ onDestroy(() => {
+ // Clean up
+ if (this.#hiddenTextarea) {
+ this.#hiddenTextarea.remove();
+ this.#hiddenTextarea = null;
+ }
+
+ if (this.#resizeTimeout) {
+ window.cancelAnimationFrame(this.#resizeTimeout);
+ this.#resizeTimeout = null;
+ }
+ });
+ }
+
+ #createHiddenTextarea() {
+ // Create a hidden textarea that will be used for measurements
+ // This avoids layout shifts caused by manipulating the actual textarea
+ if (typeof window === 'undefined') return;
+
+ this.#hiddenTextarea = document.createElement('textarea');
+ const style = this.#hiddenTextarea.style;
+
+ // Make it invisible but keep same text layout properties
+ style.visibility = 'hidden';
+ style.position = 'absolute';
+ style.overflow = 'hidden';
+ style.height = '0';
+ style.top = '0';
+ style.left = '-9999px';
+
+ document.body.appendChild(this.#hiddenTextarea);
+ }
+
+ #copyStyles() {
+ if (!this.element || !this.#hiddenTextarea) return;
+
+ const computed = window.getComputedStyle(this.element);
+
+ // Copy all the styles that affect text layout
+ const stylesToCopy = [
+ 'box-sizing',
+ 'width',
+ 'padding-top',
+ 'padding-right',
+ 'padding-bottom',
+ 'padding-left',
+ 'border-top-width',
+ 'border-right-width',
+ 'border-bottom-width',
+ 'border-left-width',
+ 'font-family',
+ 'font-size',
+ 'font-weight',
+ 'font-style',
+ 'letter-spacing',
+ 'text-indent',
+ 'text-transform',
+ 'line-height',
+ 'word-spacing',
+ 'word-wrap',
+ 'word-break',
+ 'white-space',
+ ];
+
+ stylesToCopy.forEach((style) => {
+ this.#hiddenTextarea!.style.setProperty(style, computed.getPropertyValue(style));
+ });
+
+ // Ensure the width matches exactly
+ this.#hiddenTextarea.style.width = `${this.element.clientWidth}px`;
+ }
+
+ triggerResize = () => {
+ if (!this.element || !this.#hiddenTextarea) return;
+
+ // Copy current styles and content to hidden textarea
+ this.#copyStyles();
+ this.#hiddenTextarea.value = this.#input || '';
+
+ // Measure the hidden textarea
+ const scrollHeight = this.#hiddenTextarea.scrollHeight;
+
+ // Apply the height, respecting maxHeight if set
+ let newHeight = scrollHeight;
+ if (this.maxHeight && newHeight > this.maxHeight) {
+ newHeight = this.maxHeight;
+ this.element.style.overflowY = 'auto';
+ } else {
+ this.element.style.overflowY = 'hidden';
+ }
+
+ // Only update if height actually changed
+ if (this.textareaHeight !== newHeight) {
+ this.textareaHeight = newHeight;
+ this.element.style[this.styleProp] = `${newHeight}px`;
+ }
+ };
+
+ attachment: Attachment = (node) => {
+ this.element = node;
+ this.#input = node.value;
+
+ // Detect programmatic changes
+ const desc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!;
+ Object.defineProperty(node, 'value', {
+ get: desc.get,
+ set: (v) => {
+ this.#input = v;
+ queueMicrotask(this.triggerResize);
+ desc.set?.call(node, v);
+ },
+ });
+
+ queueMicrotask(this.triggerResize);
+
+ const removeListener = on(node, 'input', () => {
+ this.#input = node.value;
+ this.triggerResize();
+ });
+
+ return () => {
+ removeListener();
+ this.#input = '';
+ this.element = undefined;
+ };
+ };
+}
diff --git a/src/lib/utils/fuzzy-search.ts b/src/lib/utils/fuzzy-search.ts
new file mode 100644
index 0000000..69299fe
--- /dev/null
+++ b/src/lib/utils/fuzzy-search.ts
@@ -0,0 +1,64 @@
+/**
+ * Generic fuzzy search function that searches through arrays and returns matching items
+ *
+ * @param options Configuration object for the fuzzy search
+ * @returns Array of items that match the search criteria
+ */
+export default function fuzzysearch(options: {
+ needle: string;
+ haystack: T[];
+ property: keyof T | ((item: T) => string);
+}): T[] {
+ const { needle, haystack, property } = options;
+
+ if (!Array.isArray(haystack)) {
+ throw new Error('Haystack must be an array');
+ }
+
+ if (!property) {
+ throw new Error('Property selector is required');
+ }
+
+ // Convert needle to lowercase for case-insensitive matching
+ const lowerNeedle = needle.toLowerCase();
+
+ // Filter the haystack to find matching items
+ return haystack.filter((item) => {
+ // Extract the string value from the item based on the property selector
+ const value = typeof property === 'function' ? property(item) : String(item[property]);
+
+ // Convert to lowercase for case-insensitive matching
+ const lowerValue = value.toLowerCase();
+
+ // Perform the fuzzy search
+ return fuzzyMatchString(lowerNeedle, lowerValue);
+ });
+}
+
+/**
+ * Internal helper function that performs the actual fuzzy string matching
+ */
+function fuzzyMatchString(needle: string, haystack: string): boolean {
+ const hlen = haystack.length;
+ const nlen = needle.length;
+
+ if (nlen > hlen) {
+ return false;
+ }
+
+ if (nlen === hlen) {
+ return needle === haystack;
+ }
+
+ outer: for (let i = 0, j = 0; i < nlen; i++) {
+ const nch = needle.charCodeAt(i);
+ while (j < hlen) {
+ if (haystack.charCodeAt(j++) === nch) {
+ continue outer;
+ }
+ }
+ return false;
+ }
+
+ return true;
+}
diff --git a/src/lib/utils/object.ts b/src/lib/utils/object.ts
index 12c8749..a9a2b70 100644
--- a/src/lib/utils/object.ts
+++ b/src/lib/utils/object.ts
@@ -20,3 +20,39 @@ export function pick(obj: T, keys: K[]): Pi
K
>;
}
+
+/**
+ * Transforms an object into a new object by applying a mapping function
+ * to each of its key-value pairs.
+ *
+ * @template KIn The type of the keys in the input object.
+ * @template VIn The type of the values in the input object.
+ * @template KOut The type of the keys in the output object.
+ * @template VOut The type of the values in the output object.
+ *
+ * @param obj The input object to transform.
+ * @param mapper A function that takes a key and its value from the input object
+ * and returns a tuple `[KOut, VOut]` representing the new key
+ * and new value for the output object.
+ * @returns A new object with the transformed keys and values.
+ */
+export function objectMap<
+ KIn extends string | number | symbol,
+ VIn,
+ KOut extends string | number | symbol,
+ VOut,
+>(obj: Record, mapper: (key: KIn, value: VIn) => [KOut, VOut]): Record {
+ const result: Record = {} as Record;
+
+ for (const rawKey in obj) {
+ // Ensure we only process own properties (not inherited ones)
+ if (Object.prototype.hasOwnProperty.call(obj, rawKey)) {
+ const key = rawKey as KIn; // Cast to KIn as rawKey is initially string
+ const value = obj[key];
+ const [newKey, newValue] = mapper(key, value);
+ result[newKey] = newValue;
+ }
+ }
+
+ return result;
+}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index d78b20e..ee0dbaa 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -4,6 +4,7 @@
import { ModeWatcher } from 'mode-watcher';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { models } from '$lib/state/models.svelte';
+ import GlobalModal from '$lib/components/ui/modal/global-modal.svelte';
let { children } = $props();
@@ -13,3 +14,5 @@
{@render children()}
+
+
diff --git a/src/routes/account/customization/rule.svelte b/src/routes/account/customization/rule.svelte
index 228ff3e..f5b94b3 100644
--- a/src/routes/account/customization/rule.svelte
+++ b/src/routes/account/customization/rule.svelte
@@ -10,6 +10,7 @@
import { LocalToasts } from '$lib/builders/local-toasts.svelte';
import { ResultAsync } from 'neverthrow';
import TrashIcon from '~icons/lucide/trash';
+ import { callModal } from '$lib/components/ui/modal/global-modal.svelte';
type Props = {
rule: Doc<'user_rules'>;
@@ -57,6 +58,15 @@
}
async function deleteRule() {
+ const action = await callModal({
+ title: 'Delete Rule',
+ description: 'Are you sure you want to delete this rule?',
+ actions: {
+ delete: 'destructive',
+ },
+ });
+ if (action !== 'delete') return;
+
deleting = true;
await client.mutation(api.user_rules.remove, {
diff --git a/src/routes/account/models/+page.svelte b/src/routes/account/models/+page.svelte
index 8329efe..003c1ec 100644
--- a/src/routes/account/models/+page.svelte
+++ b/src/routes/account/models/+page.svelte
@@ -11,6 +11,7 @@
import XIcon from '~icons/lucide/x';
import PlusIcon from '~icons/lucide/plus';
import { models } from '$lib/state/models.svelte';
+ import fuzzysearch from '$lib/utils/fuzzy-search';
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
provider: Provider.OpenRouter,
@@ -30,12 +31,7 @@
});
const openRouterModels = $derived(
- models.from(Provider.OpenRouter).filter((model) => {
- if (search !== '' && !hasOpenRouterKey) return false;
- if (!openRouterToggle.value) return false;
-
- return model.name.toLowerCase().includes(search.toLowerCase());
- })
+ fuzzysearch({ haystack: models.from(Provider.OpenRouter), needle: search, property: 'name' })
);
diff --git a/src/routes/chat/+layout.svelte b/src/routes/chat/+layout.svelte
index 662e21e..e6b37ca 100644
--- a/src/routes/chat/+layout.svelte
+++ b/src/routes/chat/+layout.svelte
@@ -14,6 +14,7 @@
import { goto } from '$app/navigation';
import { useCachedQuery } from '$lib/cache/cached-query.svelte.js';
import { api } from '$lib/backend/convex/_generated/api.js';
+ import { TextareaAutosize } from '$lib/spells/textarea-autosize.svelte.js';
let { data, children } = $props();
@@ -45,6 +46,8 @@
const conversationsQuery = useCachedQuery(api.conversations.get, {
session_token: session.current?.session.token ?? '',
});
+
+ const _autosize = new TextareaAutosize();
@@ -108,6 +111,8 @@
}}
bind:this={form}
>
+
+