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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user