From 58a11ca872c0ce45568e609cf57bca6a8e3ffdce Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 20 Jul 2026 10:32:54 +0200 Subject: [PATCH] feat(web): resizable panels via svelte-splitpanes + Claude-style composer Replace hand-rolled pointer-resize logic (TaskContextPanel's 3-way vertical split, SessionChatWindow's rail, ChatThread's message/input split) with svelte-splitpanes, themed onto the app's existing border/primary tokens. - TaskContextPanel: Scope/Plan/Event-log sections collapse to a fixed header height and restore their last size on reopen. - ChatThread: input area is now a separate resizable pane, clamped to a measured one-line minimum and a 45% max, instead of a fixed max-h textarea. - Send button restyled to sit inside the input's corner (Claude-style), swapping the up-arrow for a corner-down-left return icon. - Adds a $app/environment shim + optimizeDeps exclude, since svelte-splitpanes assumes SvelteKit and this is a plain Vite app. Co-Authored-By: Claude Sonnet 5 --- web/package-lock.json | 31 +-- web/package.json | 1 + web/src/app.css | 29 +++ web/src/lib/components/ChatThread.svelte | 117 +++++++--- .../lib/components/SessionChatWindow.svelte | 72 ++---- .../lib/components/TaskContextPanel.svelte | 208 ++++++++---------- web/src/lib/shims/app-environment.ts | 4 + web/vite.config.ts | 18 +- 8 files changed, 268 insertions(+), 212 deletions(-) create mode 100644 web/src/lib/shims/app-environment.ts diff --git a/web/package-lock.json b/web/package-lock.json index fa44f4d..4b64324 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,6 +13,7 @@ "d3-force": "^3.0.0", "dompurify": "^3.4.11", "marked": "^18.0.5", + "svelte-splitpanes": "^8.0.12", "tailwind-merge": "^3.6.0", "uplot": "^1.6.32" }, @@ -877,7 +878,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -888,7 +888,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -899,7 +898,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -909,14 +907,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1320,7 +1316,6 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", - "devOptional": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1677,7 +1672,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1691,7 +1685,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "devOptional": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -1844,7 +1837,7 @@ "version": "8.64.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2066,7 +2059,6 @@ "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -2139,7 +2131,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -2166,7 +2157,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -2548,7 +2538,6 @@ "version": "5.8.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", - "devOptional": true, "license": "MIT" }, "node_modules/dompurify": { @@ -2858,7 +2847,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "devOptional": true, "license": "MIT" }, "node_modules/espree": { @@ -2896,7 +2884,6 @@ "version": "2.2.13", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", - "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -3364,7 +3351,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -3788,7 +3774,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "devOptional": true, "license": "MIT" }, "node_modules/locate-path": { @@ -3842,7 +3827,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -4504,7 +4488,6 @@ "version": "5.56.4", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", - "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -4659,6 +4642,15 @@ "svelte": "^5.7.0" } }, + "node_modules/svelte-splitpanes": { + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/svelte-splitpanes/-/svelte-splitpanes-8.0.12.tgz", + "integrity": "sha512-HJ07HgbtY0Q/35TEuJquGy47dtgCVavV7ay9r1FhWRx3boyUs3RpeiHlwMKliznCKy2ZotNeT+8GG+mIoNjgRA==", + "license": "MIT", + "peerDependencies": { + "svelte": "^5.43.0" + } + }, "node_modules/svelte-toolbelt": { "version": "0.10.6", "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", @@ -6315,7 +6307,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "devOptional": true, "license": "MIT" } } diff --git a/web/package.json b/web/package.json index 435a850..64688f8 100644 --- a/web/package.json +++ b/web/package.json @@ -45,6 +45,7 @@ "d3-force": "^3.0.0", "dompurify": "^3.4.11", "marked": "^18.0.5", + "svelte-splitpanes": "^8.0.12", "tailwind-merge": "^3.6.0", "uplot": "^1.6.32" } diff --git a/web/src/app.css b/web/src/app.css index 16090f2..e3627b7 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -289,3 +289,32 @@ a { a:hover { text-decoration: underline; } + +/* svelte-splitpanes theming (TaskContextPanel, SessionChatWindow rail) — + mapped onto the app's border/primary tokens instead of the library's + default-theme, so splitters follow the terracotta/dark theme toggle. */ +.splitpanes.oikos-theme .splitpanes__pane { + background: transparent; +} + +.splitpanes.oikos-theme .splitpanes__splitter { + background-color: transparent; + transition: background-color 0.15s; +} + +.splitpanes.oikos-theme .splitpanes__splitter:hover, +.splitpanes.oikos-theme .splitpanes__splitter.splitpanes__splitter__active { + background-color: color-mix(in oklab, var(--primary) 30%, transparent); +} + +.oikos-theme.splitpanes--horizontal > .splitpanes__splitter { + height: 6px; + border-bottom: 1px solid var(--border); + cursor: row-resize; +} + +.oikos-theme.splitpanes--vertical > .splitpanes__splitter { + width: 6px; + border-left: 1px solid var(--border); + cursor: col-resize; +} diff --git a/web/src/lib/components/ChatThread.svelte b/web/src/lib/components/ChatThread.svelte index eb0473c..f081824 100644 --- a/web/src/lib/components/ChatThread.svelte +++ b/web/src/lib/components/ChatThread.svelte @@ -4,11 +4,12 @@ // window (its own per-session store bundle from chat.ts's chatFor) render // through this, so the message-bubble/markdown styling lives in one place // instead of being copy-pasted between the two. + import { Pane, Splitpanes } from 'svelte-splitpanes' import { activityLog } from '$lib/stores/activity' import AgentIndicator from '$lib/components/AgentIndicator.svelte' import { Button } from '$lib/components/ui/button' import { Textarea } from '$lib/components/ui/textarea' - import ArrowUpIcon from '@lucide/svelte/icons/arrow-up' + import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left' import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw' import SquareIcon from '@lucide/svelte/icons/square' import { marked } from 'marked' @@ -44,6 +45,44 @@ let scrolledUp = $state(false) let container = $state(null) + // Resizable input area — drag the splitter above it to grow the textarea, + // capped so it can't swallow the whole thread. Both the minimum and the + // default are exactly one line: measured from the textarea's own + // line-height/padding/border rather than hardcoded, so it stays correct if + // that styling ever changes. + let threadHeight = $state(0) + let textareaRef = $state(null) + let inputWrapperRef = $state(null) + let oneLinePx = $state(64) + $effect(() => { + if (!textareaRef || !inputWrapperRef) return + const taCs = getComputedStyle(textareaRef) + const lineHeight = parseFloat(taCs.lineHeight) + if (!Number.isFinite(lineHeight)) return + const taBoxY = + parseFloat(taCs.paddingTop) + parseFloat(taCs.paddingBottom) + parseFloat(taCs.borderTopWidth) + parseFloat(taCs.borderBottomWidth) + // The wrapper's own padding/border (space around the textarea, not part + // of it) also has to fit inside the minimum, or the textarea gets + // squeezed below one line once the pane is dragged down to it. + const wrapperCs = getComputedStyle(inputWrapperRef) + const wrapperBoxY = + parseFloat(wrapperCs.paddingTop) + + parseFloat(wrapperCs.paddingBottom) + + parseFloat(wrapperCs.borderTopWidth) + + parseFloat(wrapperCs.borderBottomWidth) + oneLinePx = lineHeight + taBoxY + wrapperBoxY + }) + const inputMinSize = $derived(threadHeight > 0 ? (oneLinePx / threadHeight) * 100 : 12) + + let inputSize = $state(12) + let inputSizeDefaulted = false + $effect(() => { + if (!inputSizeDefaulted && threadHeight > 0) { + inputSize = inputMinSize + inputSizeDefaulted = true + } + }) + function isNearBottom(): boolean { if (!container) return true const { scrollTop, scrollHeight, clientHeight } = container @@ -87,7 +126,9 @@ } -
+
+ +
{#if messages.length === 0} @@ -169,34 +210,52 @@
{/each} +
-
-
{ - e.preventDefault() - submit() - }} - > -