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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<HTMLDivElement | null>(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<HTMLTextAreaElement | null>(null)
|
||||
let inputWrapperRef = $state<HTMLDivElement | null>(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 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div class="flex h-full min-h-0 min-w-0 flex-col" bind:clientHeight={threadHeight}>
|
||||
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
|
||||
<Pane class="flex flex-col">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
|
||||
{#if messages.length === 0}
|
||||
@@ -169,34 +210,52 @@
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</Pane>
|
||||
|
||||
<div class="border-t bg-card/50 p-3 input-ornament relative">
|
||||
<form
|
||||
class="mx-auto flex max-w-3xl items-end gap-2"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
bind:value={input}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Ask Nomos anything…"
|
||||
rows={2}
|
||||
class="max-h-40 min-h-0 resize-none"
|
||||
disabled={streaming}
|
||||
/>
|
||||
{#if streaming}
|
||||
<Button type="button" size="icon" variant="destructive" onclick={onCancel} aria-label="Stop">
|
||||
<SquareIcon />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Send">
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
<Pane bind:size={inputSize} minSize={inputMinSize} maxSize={45} class="flex flex-col">
|
||||
<div class="flex h-full min-h-0 flex-col border-t bg-card/50 p-3 input-ornament relative" bind:this={inputWrapperRef}>
|
||||
<form
|
||||
class="relative mx-auto flex h-full w-full max-w-3xl"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
bind:ref={textareaRef}
|
||||
bind:value={input}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Ask Nomos anything…"
|
||||
class="h-full max-h-none min-h-0 resize-none rounded-2xl px-4 py-3 pr-12 field-sizing-fixed"
|
||||
disabled={streaming}
|
||||
/>
|
||||
{#if streaming}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="secondary"
|
||||
class="absolute right-2 bottom-2 rounded-lg"
|
||||
onclick={onCancel}
|
||||
aria-label="Stop"
|
||||
>
|
||||
<SquareIcon class="size-3.5" />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon-sm"
|
||||
variant="secondary"
|
||||
class="absolute right-2 bottom-2 rounded-lg"
|
||||
disabled={!input.trim()}
|
||||
aria-label="Send"
|
||||
>
|
||||
<CornerDownLeftIcon class="size-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user