feat(web): resizable panels via svelte-splitpanes + Claude-style composer
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

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:
2026-07-20 10:32:54 +02:00
parent aed068de12
commit 58a11ca872
8 changed files with 268 additions and 212 deletions

View File

@@ -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>

View File

@@ -6,6 +6,7 @@
// these can be open (and independently live) at once without the "which
// one's on screen" guarding the main page's singleton stores need.
import { onDestroy, onMount } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { chatFor, loadSessionChat, sendSessionMessage, cancelSessionStream, stopSessionPolling, dismissError, chatErrors } from '$lib/stores/chat'
import ChatThread from '$lib/components/ChatThread.svelte'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
@@ -30,29 +31,9 @@
onDestroy(() => stopSessionPolling(sessionId))
// Resizable right rail — same behavior as Chat.svelte's, sized smaller by
// default since task windows open narrower than the full page.
const RAIL_MIN = 220
const RAIL_MAX = 480
let railWidth = $state(260)
let resizing = $state(false)
function startResize(e: PointerEvent) {
e.preventDefault()
resizing = true
const startX = e.clientX
const startW = railWidth
function move(ev: PointerEvent) {
railWidth = Math.min(RAIL_MAX, Math.max(RAIL_MIN, startW + (startX - ev.clientX)))
}
function up() {
resizing = false
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
// Resizable right rail — sized smaller by default since task windows open
// narrower than the full page.
let railSize = $state(24)
</script>
<div class="flex h-full min-h-0">
@@ -64,34 +45,23 @@
<p class="text-xs text-muted-foreground/70">It may have been deleted.</p>
</div>
{:else}
<ChatThread
messages={$chatMessages}
streaming={$chatStreaming}
connectionState={$chatConnectionState}
error={$chatError}
chatErrors={$chatErrors}
onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)}
onDismissError={dismissError}
/>
<div class="flex shrink-0" style="width: {railWidth}px">
<button
type="button"
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
onpointerdown={startResize}
aria-label="Resize task panel"
>
<span
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
? 'bg-primary/60'
: 'bg-border group-hover/rz:bg-primary/50'}"
></span>
</button>
<div class="flex min-w-0 flex-1 flex-col">
<Splitpanes theme="oikos-theme" dblClickSplitter={false}>
<Pane>
<ChatThread
messages={$chatMessages}
streaming={$chatStreaming}
connectionState={$chatConnectionState}
error={$chatError}
chatErrors={$chatErrors}
onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)}
onDismissError={dismissError}
/>
</Pane>
<Pane bind:size={railSize} minSize={18} maxSize={40}>
<TaskContextPanel {sessionId} />
</div>
</div>
</Pane>
</Splitpanes>
{/if}
</div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { startWorkspace, startSessionWorkspace, planSteps, currentTask, openQuestion, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace'
import { streaming, messages, currentSession, chatFor } from '$lib/stores/chat'
import { activityLog, activityLogFor } from '$lib/stores/activity'
@@ -44,39 +45,27 @@
let planOpen = $state(true)
let activityOpen = $state(true)
// Resize: distribute height across the 3 content areas.
// Heights stored in px, minus header sizes. Default even split.
let heights = $state([300, 200, 200])
let resizing = $state(-1)
let resizeStartY = $state(0)
let resizeStartH = $state<[number, number]>([0, 0])
// Resize: each section is a Pane in one vertical Splitpanes. Sizes are
// percentages of the panel's height; undefined means "share the space
// evenly with the other auto sections". Collapsing a section pins it to
// COLLAPSED_SIZE (roughly a header's worth of height) and remembers its
// last size so reopening restores it.
const COLLAPSED_SIZE = 6
const OPEN_MIN_SIZE = 12
let sizes = $state<(number | undefined)[]>([undefined, undefined, undefined])
// Reopening must restore a concrete number, never `undefined` — the pane
// only re-triggers the library's resize/equalize pass when `size` changes
// to a different *number*, so setting it back to `undefined` silently
// no-ops and leaves the section stuck at its collapsed height.
let savedSizes: number[] = [34, 33, 33]
function onPointerDown(i: number, e: PointerEvent) {
e.preventDefault()
resizing = i
resizeStartY = e.clientY
resizeStartH = [heights[i], heights[i + 1]]
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(e: PointerEvent) {
if (resizing < 0) return
const dy = e.clientY - resizeStartY
const minH = 80
// Clamp each section to minimum
const newA = Math.max(minH, resizeStartH[0] + dy)
const newB = Math.max(minH, resizeStartH[1] - dy)
const newHeights = [...heights]
newHeights[resizing] = newA
newHeights[resizing + 1] = newB
heights = newHeights
}
function onPointerUp() {
resizing = -1
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
function toggleSection(i: number, isOpen: boolean) {
if (isOpen) {
savedSizes[i] = sizes[i] ?? savedSizes[i]
sizes[i] = COLLAPSED_SIZE
} else {
sizes[i] = savedSizes[i]
}
}
// Plan collapsed status
@@ -103,54 +92,54 @@
<div class="flex h-full min-h-0 flex-col">
<OperatorQuestion sessionId={effectiveSessionId} question={$openQuestionStore} />
<!-- Scope -->
<div class="flex shrink-0 flex-col border-b">
<button
type="button"
class="flex items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
onclick={() => (scopeOpen = !scopeOpen)}
>
{#if scopeOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
<span>Scope</span>
{#if !scopeOpen}
<span class="ml-auto font-normal normal-case">{$touchedStore.length ? `${$touchedStore.length} entit${$touchedStore.length === 1 ? 'y' : 'ies'}` : 'Graph'}</span>
{/if}
</button>
{#if scopeOpen}
<div style="height: {heights[0]}px">
<SessionGraph messages={$messagesStore} touched={$touchedStore} healthDiffs={$healthDiffsStore} />
</div>
<!-- resize handle -->
<div
class="h-1.5 cursor-row-resize border-b hover:bg-primary/30 touch-none"
onpointerdown={(e) => onPointerDown(0, e)}
role="separator"
aria-orientation="horizontal"
></div>
{/if}
</div>
<!-- Plan -->
<div class="flex shrink-0 flex-col border-b">
<button
type="button"
class="flex items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
onclick={() => (planOpen = !planOpen)}
>
{#if planOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
<span>Plan</span>
{#if !planOpen}
{#if planTotal > 0}
<span class="ml-auto font-normal normal-case">Step {planDone}/{planTotal}</span>
{:else if $taskStore?.goal}
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$taskStore.goal}</span>
{:else}
<span class="ml-auto font-normal normal-case text-muted-foreground">No plan yet</span>
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
<!-- Scope -->
<Pane bind:size={sizes[0]} minSize={scopeOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={scopeOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
<button
type="button"
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
onclick={() => {
toggleSection(0, scopeOpen)
scopeOpen = !scopeOpen
}}
>
{#if scopeOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
<span>Scope</span>
{#if !scopeOpen}
<span class="ml-auto font-normal normal-case">{$touchedStore.length ? `${$touchedStore.length} entit${$touchedStore.length === 1 ? 'y' : 'ies'}` : 'Graph'}</span>
{/if}
</button>
{#if scopeOpen}
<div class="min-h-0 flex-1">
<SessionGraph messages={$messagesStore} touched={$touchedStore} healthDiffs={$healthDiffsStore} />
</div>
{/if}
</button>
{#if planOpen}
<div style="height: {heights[1]}px" class="flex flex-col overflow-y-auto">
</Pane>
<!-- Plan -->
<Pane bind:size={sizes[1]} minSize={planOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={planOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
<button
type="button"
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
onclick={() => {
toggleSection(1, planOpen)
planOpen = !planOpen
}}
>
{#if planOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
<span>Plan</span>
{#if !planOpen}
{#if planTotal > 0}
<span class="ml-auto font-normal normal-case">Step {planDone}/{planTotal}</span>
{:else if $taskStore?.goal}
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$taskStore.goal}</span>
{:else}
<span class="ml-auto font-normal normal-case text-muted-foreground">No plan yet</span>
{/if}
{/if}
</button>
{#if planOpen}
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
{#if $taskStore?.goal}
<div class="flex items-start gap-2 px-3 py-2">
<MilestoneIcon class="mt-0.5 size-3 shrink-0 text-primary" />
@@ -244,38 +233,35 @@
</div>
{/if}
</div>
<!-- resize handle -->
<div
class="h-1.5 cursor-row-resize border-b hover:bg-primary/30 touch-none"
onpointerdown={(e) => onPointerDown(1, e)}
role="separator"
aria-orientation="horizontal"
></div>
{/if}
</div>
<!-- Activity -->
<div class="flex min-h-0 flex-1 flex-col">
<button
type="button"
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
onclick={() => (activityOpen = !activityOpen)}
>
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
<span>Event log</span>
{#if !activityOpen}
{#if $streamingStore && activityRunning > 0}
<Spinner class="size-3 text-primary" />
<span class="font-normal normal-case text-primary">{activityRunning} running</span>
{:else}
<span class="ml-auto font-normal normal-case">{activityCount || '—'} action{activityCount === 1 ? '' : 's'}</span>
{/if}
{/if}
</button>
{#if activityOpen}
<div class="min-h-0 flex-1 overflow-hidden">
<ActivityTimeline entries={$activityLogStore} />
</div>
{/if}
</div>
</Pane>
<!-- Activity -->
<Pane bind:size={sizes[2]} minSize={activityOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={activityOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
<button
type="button"
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
onclick={() => {
toggleSection(2, activityOpen)
activityOpen = !activityOpen
}}
>
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
<span>Event log</span>
{#if !activityOpen}
{#if $streamingStore && activityRunning > 0}
<Spinner class="size-3 text-primary" />
<span class="font-normal normal-case text-primary">{activityRunning} running</span>
{:else}
<span class="ml-auto font-normal normal-case">{activityCount || '—'} action{activityCount === 1 ? '' : 's'}</span>
{/if}
{/if}
</button>
{#if activityOpen}
<div class="min-h-0 flex-1 overflow-hidden">
<ActivityTimeline entries={$activityLogStore} />
</div>
{/if}
</Pane>
</Splitpanes>
</div>