diff --git a/package-lock.json b/package-lock.json index 3a539cb..6ca9851 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@agnosticeng/dv": "^0.0.9", "@agnosticeng/migrate": "^0.0.2", "@agnosticeng/sqlite": "^0.0.5", + "@floating-ui/dom": "^1.6.13", "@observablehq/plot": "^0.6.16", "@rich_harris/svelte-split-pane": "^2.0.0", "@tauri-apps/api": "^2.2.0", @@ -20,7 +21,10 @@ "@tauri-apps/plugin-fs": "^2.2.0", "d3": "^7.9.0", "dayjs": "^1.11.13", + "highlight.js": "^11.11.1", "lodash": "^4.17.21", + "marked": "^15.0.7", + "marked-highlight": "^2.2.1", "monaco-editor": "^0.52.2", "normalize.css": "^8.0.1", "p-debounce": "^4.0.0", @@ -511,6 +515,31 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -2144,6 +2173,15 @@ "dev": true, "license": "MIT" }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -2231,6 +2269,27 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/marked": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.7.tgz", + "integrity": "sha512-dgLIeKGLx5FwziAnsk4ONoGwHwGPJzselimvlVskE9XLN4Orv9u2VA3GWw/lYUqjfA0rUT/6fqKwfZJapP9BEg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/marked-highlight": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.2.1.tgz", + "integrity": "sha512-SiCIeEiQbs9TxGwle9/OwbOejHCZsohQRaNTY2u8euEXYt2rYUFoiImUirThU3Gd/o6Q1gHGtH9qloHlbJpNIA==", + "license": "MIT", + "peerDependencies": { + "marked": ">=4 <16" + } + }, "node_modules/monaco-editor": { "version": "0.52.2", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", diff --git a/package.json b/package.json index 63b8f9d..9f9d18c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@agnosticeng/dv": "^0.0.9", "@agnosticeng/migrate": "^0.0.2", "@agnosticeng/sqlite": "^0.0.5", + "@floating-ui/dom": "^1.6.13", "@observablehq/plot": "^0.6.16", "@rich_harris/svelte-split-pane": "^2.0.0", "@tauri-apps/api": "^2.2.0", @@ -26,7 +27,10 @@ "@tauri-apps/plugin-fs": "^2.2.0", "d3": "^7.9.0", "dayjs": "^1.11.13", + "highlight.js": "^11.11.1", "lodash": "^4.17.21", + "marked": "^15.0.7", + "marked-highlight": "^2.2.1", "monaco-editor": "^0.52.2", "normalize.css": "^8.0.1", "p-debounce": "^4.0.0", diff --git a/src/lib/actions/autoresize.svelte.ts b/src/lib/actions/autoresize.svelte.ts new file mode 100644 index 0000000..af70bdc --- /dev/null +++ b/src/lib/actions/autoresize.svelte.ts @@ -0,0 +1,14 @@ +import { tick } from 'svelte'; +import type { Action } from 'svelte/action'; +import { on } from 'svelte/events'; + +export const autoresize = ((tx) => { + async function onInput() { + await tick(); + tx.style.height = '0px'; + tx.style.height = `${tx.scrollHeight}px`; + } + + onInput(); + $effect(() => on(tx, 'input', onInput)); +}) satisfies Action; diff --git a/src/lib/actions/portal.svelte.ts b/src/lib/actions/portal.svelte.ts new file mode 100644 index 0000000..fd4718d --- /dev/null +++ b/src/lib/actions/portal.svelte.ts @@ -0,0 +1,37 @@ +import { tick } from 'svelte'; + +type Target = HTMLElement | string; + +export function portal(node: HTMLElement, target: Target = 'body') { + let targetNode; + + async function update() { + if (typeof target === 'string') { + targetNode = document.querySelector(target); + if (targetNode === null) { + await tick(); + targetNode = document.querySelector(target); + } + if (targetNode === null) { + throw new Error(`Invalid CSS selector: "${target}"`); + } + } else if (target instanceof HTMLElement) { + targetNode = target; + } else { + throw new TypeError( + `Unknown portal target type: ${ + target === null ? 'null' : typeof target + }. Allowed types: string (CSS selector) or HTMLElement.` + ); + } + targetNode.appendChild(node); + node.setAttribute('data-portal', ''); + } + + $effect(() => { + update(); + return () => { + if (node.parentNode) node.parentNode.removeChild(node); + }; + }); +} diff --git a/src/lib/actions/scrollToBottom.svelte.ts b/src/lib/actions/scrollToBottom.svelte.ts new file mode 100644 index 0000000..2ab7983 --- /dev/null +++ b/src/lib/actions/scrollToBottom.svelte.ts @@ -0,0 +1,17 @@ +import { tick } from 'svelte'; +import type { Action } from 'svelte/action'; + +export const scroll_to_bottom = ((node) => { + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'childList') { + tick().then(() => node.scroll({ top: node.scrollHeight, behavior: 'smooth' })); + } + } + }); + + $effect(() => { + observer.observe(node, { childList: true }); + return () => observer.disconnect(); + }); +}) satisfies Action; diff --git a/src/lib/components/Ai/Chat.svelte b/src/lib/components/Ai/Chat.svelte new file mode 100644 index 0000000..0a06353 --- /dev/null +++ b/src/lib/components/Ai/Chat.svelte @@ -0,0 +1,378 @@ + + +{#snippet context(dataset: Table)} +

{dataset.name.split('__').pop()}

+{/snippet} + +
+ + +
+ + + + + + {#if loading} + + {:else} + + {/if} +
+
+ + diff --git a/src/lib/components/Ai/DatasetsBox.svelte b/src/lib/components/Ai/DatasetsBox.svelte new file mode 100644 index 0000000..57e4678 --- /dev/null +++ b/src/lib/components/Ai/DatasetsBox.svelte @@ -0,0 +1,136 @@ + + +
+ + +
+ + diff --git a/src/lib/components/Ai/Loader.svelte b/src/lib/components/Ai/Loader.svelte new file mode 100644 index 0000000..acdceb6 --- /dev/null +++ b/src/lib/components/Ai/Loader.svelte @@ -0,0 +1,37 @@ + + + + + + + + + diff --git a/src/lib/components/Ai/Panel.svelte b/src/lib/components/Ai/Panel.svelte new file mode 100644 index 0000000..17ab553 --- /dev/null +++ b/src/lib/components/Ai/Panel.svelte @@ -0,0 +1,144 @@ + + +
+ +
+ {#if current} + (current.messages = [])} + {onOpenInEditor} + /> + {/if} +
+
+ + diff --git a/src/lib/components/Ai/index.ts b/src/lib/components/Ai/index.ts new file mode 100644 index 0000000..e851ae8 --- /dev/null +++ b/src/lib/components/Ai/index.ts @@ -0,0 +1,11 @@ +import type { Table } from '$lib/olap-engine'; +import type { ChatInput } from './types'; + +export { default as AiPanel } from './Panel.svelte'; + +export interface Chat { + id: string; + name: string; + messages: ChatInput['messages']; + dataset?: Table; +} diff --git a/src/lib/components/Ai/types.ts b/src/lib/components/Ai/types.ts new file mode 100644 index 0000000..fad6e5d --- /dev/null +++ b/src/lib/components/Ai/types.ts @@ -0,0 +1,12 @@ +export interface ChatInput { + messages: { + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + }[]; + stream?: false | undefined; +} + +export interface ChatOutput { + created_at: string; + message: { role: 'assistant'; content: string }; +} diff --git a/src/lib/components/Select.svelte b/src/lib/components/Select.svelte new file mode 100644 index 0000000..faf1b9b --- /dev/null +++ b/src/lib/components/Select.svelte @@ -0,0 +1,87 @@ + + + + +{#if opened} + +{/if} + + diff --git a/src/lib/components/Tab.svelte b/src/lib/components/Tab.svelte index d11c58f..6d9ff24 100644 --- a/src/lib/components/Tab.svelte +++ b/src/lib/components/Tab.svelte @@ -6,13 +6,13 @@ onSelect: () => void; onClose: () => void; active: boolean; - 'hide-close': boolean; + 'hide-close'?: boolean; } - let { active, label, onClose, onSelect, 'hide-close': hideClose }: Props = $props(); + let { active, label, onClose, onSelect, 'hide-close': hideClose = false }: Props = $props(); -
{}}> +
{}}> {label} {#if !hideClose} diff --git a/src/lib/icons/ArrowUp.svelte b/src/lib/icons/ArrowUp.svelte new file mode 100644 index 0000000..dd26b24 --- /dev/null +++ b/src/lib/icons/ArrowUp.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/src/lib/icons/Bold.svelte b/src/lib/icons/Bolt.svelte similarity index 100% rename from src/lib/icons/Bold.svelte rename to src/lib/icons/Bolt.svelte diff --git a/src/lib/icons/CircleStack.svelte b/src/lib/icons/CircleStack.svelte new file mode 100644 index 0000000..a3aaaf0 --- /dev/null +++ b/src/lib/icons/CircleStack.svelte @@ -0,0 +1,27 @@ + + + diff --git a/src/lib/icons/CircleStopSolid.svelte b/src/lib/icons/CircleStopSolid.svelte new file mode 100644 index 0000000..f0db648 --- /dev/null +++ b/src/lib/icons/CircleStopSolid.svelte @@ -0,0 +1,25 @@ + + + diff --git a/src/lib/icons/PanelRight.svelte b/src/lib/icons/PanelRight.svelte new file mode 100644 index 0000000..3cb13fb --- /dev/null +++ b/src/lib/icons/PanelRight.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/src/lib/icons/PaperClip.svelte b/src/lib/icons/PaperClip.svelte new file mode 100644 index 0000000..5a365ac --- /dev/null +++ b/src/lib/icons/PaperClip.svelte @@ -0,0 +1,27 @@ + + + diff --git a/src/lib/icons/Send.svelte b/src/lib/icons/Send.svelte new file mode 100644 index 0000000..652468d --- /dev/null +++ b/src/lib/icons/Send.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/src/lib/icons/Sparkles.svelte b/src/lib/icons/Sparkles.svelte new file mode 100644 index 0000000..77a3836 --- /dev/null +++ b/src/lib/icons/Sparkles.svelte @@ -0,0 +1,26 @@ + + + + + diff --git a/src/lib/icons/UserCircle.svelte b/src/lib/icons/UserCircle.svelte new file mode 100644 index 0000000..d9703b0 --- /dev/null +++ b/src/lib/icons/UserCircle.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/src/lib/icons/arrow-top-right-on-square.svg b/src/lib/icons/arrow-top-right-on-square.svg new file mode 100644 index 0000000..8387c51 --- /dev/null +++ b/src/lib/icons/arrow-top-right-on-square.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/icons/check.svg b/src/lib/icons/check.svg new file mode 100644 index 0000000..ef5253e --- /dev/null +++ b/src/lib/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/icons/copy.svg b/src/lib/icons/copy.svg new file mode 100644 index 0000000..74347ba --- /dev/null +++ b/src/lib/icons/copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/markdown/index.ts b/src/lib/markdown/index.ts new file mode 100644 index 0000000..5eb3372 --- /dev/null +++ b/src/lib/markdown/index.ts @@ -0,0 +1,45 @@ +import hljs from 'highlight.js'; +import { Marked } from 'marked'; +import './markdown.css'; + +export function transform(content: string) { + const marked = new Marked({ + renderer: { + code({ text, lang = 'text' }) { + const language = hljs.getLanguage(lang) ? lang : 'text'; + + let html = '
'; + if (lang === 'sql') { + html += '
'; + html += ''; + html += ''; + html += '
'; + } + html += `
`;
+				html += hljs.highlight(text, { language }).value;
+				html += '
'; + html += '
'; + + return html; + } + } + }); + + return marked.parse(content, { async: false }); +} + +export function getTextFromElement(element: HTMLElement) { + let result = ''; + + for (const child of element.childNodes ?? []) { + if (child.nodeType === Node.TEXT_NODE) { + result += (child as Text).data; + } + + if (child.nodeType === Node.ELEMENT_NODE) { + result += getTextFromElement(child as HTMLElement); + } + } + + return result; +} diff --git a/src/lib/markdown/markdown.css b/src/lib/markdown/markdown.css new file mode 100644 index 0000000..2944ca5 --- /dev/null +++ b/src/lib/markdown/markdown.css @@ -0,0 +1,184 @@ +@import 'highlight.js/styles/default.min.css'; +@import 'highlight.js/styles/vs2015.min.css'; + +.markdown { + width: 100%; + overflow-x: auto; + word-break: break-word; + + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 0; + } + + & > p:first-child { + margin-top: 0; + } + + & > p:last-child { + margin-bottom: 0; + } + + a { + color: palevioletred; + text-decoration: underline; + } + + code { + font-family: monospace; + font-size: 12px; + } + + code:not(pre > code) { + background-color: #333; + border-radius: 3px; + padding: 0 2px; + } + + li { + margin-bottom: 10px; + } + + pre { + font-family: Menlo, Consolas, monospace; + margin: 0; + position: relative; + width: 100%; + box-sizing: border-box; + overflow-x: auto; + } + + code.hljs { + text-align: left; + border-radius: 8px; + word-wrap: normal; + hyphens: none; + line-height: 1.5; + tab-size: 4; + white-space: pre; + word-break: normal; + word-spacing: normal; + } + + .code-block { + position: relative; + + &:hover > .controls { + opacity: 1; + } + + .controls { + opacity: 0; + transition: opacity 0.2s; + + z-index: 1; + position: absolute; + top: 6px; + right: 6px; + + display: flex; + align-items: center; + justify-content: end; + + & > button { + height: 20px; + aspect-ratio: 1; + border: 1px solid hsl(0 0% 20%); + border-radius: 4px; + background-color: hsl(0deg 0% 5%); + + &:hover { + background-color: hsl(0deg 0% 20%); + } + + &:has(~ button) { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + } + + & ~ button { + border-left: none; + border-bottom-left-radius: 0; + border-top-left-radius: 0; + } + + &.copy { + position: relative; + + &::before, + &::after { + content: ''; + display: block; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + background: no-repeat 50% 50% / 60% 60%; + transition: opacity 0.2s; + transition-delay: 0.6s; + } + + &::before { + background-image: url($lib/icons/copy.svg); + } + + &::after { + background-image: url($lib/icons/check.svg); + opacity: 0; + } + + &:active::before { + opacity: 0; + transition: none; + } + + &:active::after { + opacity: 1; + transition: none; + } + } + + &.open { + position: relative; + &::before { + content: ''; + display: block; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + background: no-repeat 50% 50% / 60% 60%; + background-image: url($lib/icons/arrow-top-right-on-square.svg); + } + } + } + } + } + + .hljs-attribute, + .hljs-doctag, + .hljs-keyword, + .hljs-meta .hljs-keyword, + .hljs-name, + .hljs-selector-tag { + font-weight: normal; + } + + button { + appearance: none; + outline: none; + border: none; + background: none; + padding: 0; + + &:not(:disabled):hover { + cursor: pointer; + } + } +} diff --git a/src/lib/migrations/004_create_chats_table.sql b/src/lib/migrations/004_create_chats_table.sql new file mode 100644 index 0000000..cd90624 --- /dev/null +++ b/src/lib/migrations/004_create_chats_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS chats ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + messages TEXT NOT NULL, + dataset TEXT, + idx INTEGER NOT NULL, + active BOOL UNIQUE +); diff --git a/src/lib/migrations/index.ts b/src/lib/migrations/index.ts index 52aad25..43f082e 100644 --- a/src/lib/migrations/index.ts +++ b/src/lib/migrations/index.ts @@ -3,9 +3,11 @@ import type { Migration } from '@agnosticeng/migrate'; import CREATE_HISTORY_TABLE_SCRIPT from './001_create_history_table.sql?raw'; import CREATE_QUERIES_TABLE_SCRIPT from './002_create_queries_table.sql?raw'; import CREATE_TABS_TABLE_SCRIPT from './003_create_tabs_table.sql?raw'; +import CREATE_CHATS_TABLE_SCRIPT from './004_create_chats_table.sql?raw'; export const MIGRATIONS: Migration[] = [ { name: 'create_history_table', script: CREATE_HISTORY_TABLE_SCRIPT }, { name: 'create_queries_table', script: CREATE_QUERIES_TABLE_SCRIPT }, - { name: 'create_tabs_table', script: CREATE_TABS_TABLE_SCRIPT } + { name: 'create_tabs_table', script: CREATE_TABS_TABLE_SCRIPT }, + { name: 'create_chats_table', script: CREATE_CHATS_TABLE_SCRIPT } ]; diff --git a/src/lib/olap-engine/index.ts b/src/lib/olap-engine/index.ts index f8d499e..de95581 100644 --- a/src/lib/olap-engine/index.ts +++ b/src/lib/olap-engine/index.ts @@ -21,6 +21,8 @@ export interface ColumnDescriptor { export interface Table { name: string; engine: string; + short: string; + url: string; columns: ColumnDescriptor[]; } diff --git a/src/lib/repositories/chats.ts b/src/lib/repositories/chats.ts new file mode 100644 index 0000000..4844aac --- /dev/null +++ b/src/lib/repositories/chats.ts @@ -0,0 +1,71 @@ +import type { ChatInput } from '$lib/components/Ai/types'; +import type { Table } from '$lib/olap-engine'; +import type { Database } from '$lib/store/database'; +import type { SqlValue } from '@sqlite.org/sqlite-wasm'; + +export interface Chat { + id: string; + name: string; + messages: ChatInput['messages']; + dataset?: Table; +} + +export interface ChatsRepository { + list(): Promise<[chats: Chat[], active: number]>; + save(chats: Chat[], active: number): Promise; +} + +export class SQLiteChatsRepository implements ChatsRepository { + constructor(private db: Database) {} + + async list(): Promise<[chats: Chat[], active: number]> { + const rows = await this.db.exec('select * from chats order by idx'); + const index = rows.findIndex((r) => r.active); + + return [rows.map((r) => this.fromRowToChat(r)), Math.max(0, index)]; + } + + private fromRowToChat(row: { [columnName: string]: SqlValue }): Chat { + return { + id: row.id as string, + messages: JSON.parse(row.messages as string), + name: row.name as string, + dataset: row.dataset ? JSON.parse(row.dataset as string) : undefined + }; + } + + async save(chats: Chat[], active: number): Promise { + const rows = chats.map((c, i) => this.fromChatToRow(c, i)); + + try { + await this.db.exec('BEGIN TRANSACTION;'); + await this.db.exec('DELETE FROM chats;'); + + if (rows.length) { + const values = new Array(chats.length).fill('(?,?,?,?,?,?)').join(',\n'); + const params = rows + .map((r) => [r.id, r.name, r.messages, r.dataset, r.idx, r.idx === active || null]) + .flat(); + await this.db.exec( + `INSERT INTO chats (id, name, messages, dataset, idx, active) VALUES ${values}`, + params + ); + } + + await this.db.exec('COMMIT;'); + } catch (e) { + await this.db.exec('ROLLBACK;'); + throw e; + } + } + + private fromChatToRow(chat: Chat, index: number): { [columnName: string]: SqlValue } { + return { + id: chat.id, + name: chat.name, + messages: JSON.stringify(chat.messages), + dataset: chat.dataset ? JSON.stringify(chat.dataset) : null, + idx: index + }; + } +} diff --git a/src/lib/repositories/tabs.ts b/src/lib/repositories/tabs.ts index edf3c75..7a8c424 100644 --- a/src/lib/repositories/tabs.ts +++ b/src/lib/repositories/tabs.ts @@ -27,7 +27,7 @@ export class SQLiteTabRepository implements TabRepository { await this.db.exec( `DELETE FROM tabs; INSERT INTO tabs (id, name, content, query_id, tab_index, active) -VALUES ${Array.from({ length: rows.length }).fill('(?,?,?,?,?, ?)').join(',\n')} +VALUES ${Array.from({ length: rows.length }).fill('(?,?,?,?,?,?)').join(',\n')} `, rows .map((r) => [ diff --git a/src/lib/store/database.ts b/src/lib/store/database.ts index 1577f09..c057f40 100644 --- a/src/lib/store/database.ts +++ b/src/lib/store/database.ts @@ -1,4 +1,3 @@ -import { MIGRATIONS } from '$lib/migrations'; import { IndexedDBCache } from '@agnosticeng/cache'; import { SQLite } from '@agnosticeng/sqlite'; import type { BindingSpec } from '@sqlite.org/sqlite-wasm'; diff --git a/src/lib/utilities/useResizeObserver.svelte.ts b/src/lib/utilities/useResizeObserver.svelte.ts new file mode 100644 index 0000000..a94c52c --- /dev/null +++ b/src/lib/utilities/useResizeObserver.svelte.ts @@ -0,0 +1,20 @@ +export function useResizeObserver( + target: () => HTMLElement | undefined | null, + callback: ResizeObserverCallback, + options: ResizeObserverOptions = {} +) { + let observer: ResizeObserver | undefined; + + const target_ = $derived(target()); + + $effect(() => { + if (!target_) return; + observer = new ResizeObserver(callback); + observer.observe(target_, options); + + return () => { + observer?.disconnect(); + observer = undefined; + }; + }); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 35c9d19..775f27d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,4 +1,5 @@ @@ -353,12 +381,28 @@ LIMIT 100;`; /> {/snippet} +{#snippet ai()} + { + if (isMobile) rightDrawerOpened = false; + else rightPanel.open = false; + }} + onOpenInEditor={openNewTabIfNeeded} + /> +{/snippet} +
{#if isMobile} - + {@render sidebar()} + + {@render ai()} + {/if} {#snippet a()} -
- - {#each tabs as tab, i (tab.id)} -
- -
- {/each} -
+ {/snippet} + {#snippet b()} + (errors = [])} + /> + {/snippet} +
{/snippet} {#snippet b()} - (errors = [])} - /> + {#if !isMobile} + {@render ai()} + {/if} {/snippet} {/snippet} @@ -470,7 +546,7 @@ LIMIT 100;`;
{#if cached} - from cache + from cache {/if} {#if BUILD} @@ -479,10 +555,19 @@ LIMIT 100;`; + {/if}
@@ -512,7 +597,6 @@ LIMIT 100;`; display: flex; align-items: center; height: 100%; - overflow-x: hidden; white-space: nowrap; overflow-x: auto; overflow-y: hidden; @@ -602,7 +686,7 @@ LIMIT 100;`; border-top: 1px solid hsl(0deg 0% 20%); display: flex; place-items: center; - gap: 8px; + gap: 4px; font-family: monospace; color: hsl(0deg 0% 70%); font-size: 9px; @@ -619,6 +703,7 @@ LIMIT 100;`; & > button { height: 100%; + padding: 0; aspect-ratio: 1; flex-shrink: 0; background-color: transparent;