feat(web): redesign task rail — Plan/Event log polish, entity graph resize fix

- Plan/Activity panels: humanize step titles, richer icons, empty states
  matching Scope's illustration style, pretty-printed expandable detail
- Activity log renamed to Event log; every step now expandable
- Chat: middle-truncate header title, remove redundant task-list rail and
  header stat cluster (duplicated in the sidebar), simplify markdown styling
- Fix --font-mono actually being a monospace font (was aliased to DM Sans)
- Replace rotating loader-circle spinner with a smoother fading-blade Spinner
- SessionGraph entity detail panel: resizable and self-clamping against its
  live container size (was overflowing into sibling sections), close button
- Dev launch config: fetch bearer token from the running api container so
  `npm run dev` works against the local compose stack without a hardcoded
  secret in a tracked file

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 08:22:08 +02:00
parent 3d99282897
commit 6a8efd22bb
16 changed files with 372 additions and 217 deletions

View File

@@ -18,6 +18,7 @@
import { Button } from '$lib/components/ui/button'
import EntitySheet from '$lib/components/EntitySheet.svelte'
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
import XIcon from '@lucide/svelte/icons/x'
interface Node extends Entity {
x?: number
@@ -65,6 +66,14 @@
let cw = $state(300)
let ch = $state(300)
// This component sits INSIDE one of TaskContextPanel's own resizable slots
// (Scope), so — unlike a top-level section — its total budget can change at
// any time from outside (dragging the outer Scope/Plan handle), including
// while the detail panel below is open. asideHeight tracks that live budget
// so detailHeight can self-clamp to it instead of trusting a one-time seed.
let asideEl = $state<HTMLElement | null>(null)
let asideHeight = $state(300)
function collectSlugs(value: unknown, out: Set<string>) {
if (typeof value === 'string') {
const m = value.match(SLUG_RE)
@@ -208,6 +217,15 @@
return () => ro.disconnect()
})
$effect(() => {
if (!asideEl) return
const ro = new ResizeObserver((entries) => {
asideHeight = Math.max(entries[0].contentRect.height, 1)
})
ro.observe(asideEl)
return () => ro.disconnect()
})
onDestroy(() => sim?.stop())
const healthColor: Record<string, string> = {
@@ -249,6 +267,51 @@
return typeof end === 'object' ? end.slug : end
}
// ─── graph / detail resize ───────────────────────────────────────────
// Same drag handle, same feel as TaskContextPanel's Scope/Plan/Activity
// split — but the graph side stays flex-1 (always auto-fills whatever's
// left) rather than tracking its own pixel number. Only detailHeight is
// explicit, and it's continuously clamped against asideHeight (this
// component's actual live budget) rather than a value seeded once — so
// resizing the OUTER Scope section while the detail panel is open can't
// push this panel past its container the way a one-time seed could.
const MIN_GRAPH = 80
const MIN_DETAIL = 80
const HANDLE = 6
let detailHeight = $state(200)
let resizing = $state(false)
let resizeStartY = $state(0)
let resizeStartH = $state(0)
function maxDetailHeight(): number {
return Math.max(MIN_DETAIL, asideHeight - MIN_GRAPH - HANDLE)
}
$effect(() => {
const max = maxDetailHeight()
if (detailHeight > max) detailHeight = max
})
function onPointerDown(e: PointerEvent) {
e.preventDefault()
resizing = true
resizeStartY = e.clientY
resizeStartH = detailHeight
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(e: PointerEvent) {
if (!resizing) return
const dy = e.clientY - resizeStartY
detailHeight = Math.min(maxDetailHeight(), Math.max(MIN_DETAIL, resizeStartH - dy))
}
function onPointerUp() {
resizing = false
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
// ─── drag / select ───────────────────────────────────────────────────
let dragState: { node: Node; moved: boolean } | null = null
@@ -278,7 +341,15 @@
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) selected = selected?.slug === node.slug ? null : node
if (!moved) {
const wasNull = selected === null
const next = selected?.slug === node.slug ? null : node
// A reasonable starting size on first open — the clamp effect above
// keeps it honest against the live container size from here on, so
// this doesn't need to be exact.
if (next && wasNull) detailHeight = Math.min(maxDetailHeight(), Math.round(ch * 0.45))
selected = next
}
}
const selectedRelations = $derived(
@@ -299,7 +370,7 @@
}
</script>
<aside class="flex h-full min-h-0 flex-col bg-card/40">
<aside bind:this={asideEl} class="flex h-full min-h-0 flex-col bg-card/40">
{#if nowTouching}
<div class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary">
<span class="size-1.5 animate-pulse rounded-full bg-primary"></span>
@@ -431,11 +502,26 @@
</div>
{#if selected}
<div class="max-h-[55%] shrink-0 space-y-3 overflow-y-auto border-t p-3 text-xs">
<!-- resize handle -->
<div
class="h-1.5 shrink-0 cursor-row-resize border-b hover:bg-primary/30 touch-none"
onpointerdown={onPointerDown}
role="separator"
aria-orientation="horizontal"
></div>
<div class="shrink-0 space-y-3 overflow-y-auto p-3 text-xs" style="height: {detailHeight}px">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-mono text-sm font-semibold">{selected.slug}</span>
<span class="min-w-0 flex-1 truncate font-mono text-sm font-semibold">{selected.slug}</span>
<Badge variant="outline">{selected.type}</Badge>
{#if selected.state}<Badge variant="secondary">{selected.state}</Badge>{/if}
<button
type="button"
class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={() => (selected = null)}
aria-label="Close entity detail"
>
<XIcon class="size-3.5" />
</button>
</div>
{#if selected.health}
<div class="flex items-center gap-1.5 text-muted-foreground">