diff --git a/loom/webui/frontend/package-lock.json b/loom/webui/frontend/package-lock.json index e7b326b..00dc6fd 100644 --- a/loom/webui/frontend/package-lock.json +++ b/loom/webui/frontend/package-lock.json @@ -86,7 +86,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -508,7 +507,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.1.tgz", "integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", @@ -2290,7 +2288,6 @@ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2306,7 +2303,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2318,7 +2314,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2556,7 +2551,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3239,7 +3233,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4367,7 +4360,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4540,7 +4532,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4553,7 +4544,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4989,7 +4979,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -5090,7 +5079,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5364,7 +5352,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/loom/webui/frontend/src/App.tsx b/loom/webui/frontend/src/App.tsx index ca9f41c..a3d175d 100644 --- a/loom/webui/frontend/src/App.tsx +++ b/loom/webui/frontend/src/App.tsx @@ -33,23 +33,26 @@ export default function App() {
{ setSelectedId(null); setView(v) }} sourceFilter={sourceFilter} groupFilter={groupFilter} sourceIdPrefix={sourceIdPrefix} onSourceFilter={(src) => { + setSelectedId(null) setSourceFilter(src) setGroupFilter(null) setSourceIdPrefix(null) setView("inbox") }} onGroupFilter={(grp) => { + setSelectedId(null) setGroupFilter(grp) setSourceFilter(null) setSourceIdPrefix(null) setView("inbox") }} onSourceIdPrefix={(prefix) => { + setSelectedId(null) setSourceIdPrefix(prefix) setSourceFilter(null) setGroupFilter(null) diff --git a/loom/webui/frontend/src/components/EnvelopeCard.tsx b/loom/webui/frontend/src/components/EnvelopeCard.tsx index e4505a8..cd1038e 100644 --- a/loom/webui/frontend/src/components/EnvelopeCard.tsx +++ b/loom/webui/frontend/src/components/EnvelopeCard.tsx @@ -17,6 +17,7 @@ function pickLabelColors(env: Envelope): Record { /** Compact card shown inside a Kanban column. */ export function EnvelopeCard({ envelope, active, onClick }: EnvelopeCardProps) { + const md = envelope.metadata as Record | undefined const colors = pickLabelColors(envelope) // Don't render the synthetic state/kind labels the adaptor appended ("pr", "issue", "open", "closed"). const displayLabels = envelope.labels.filter( @@ -51,7 +52,7 @@ export function EnvelopeCard({ envelope, active, onClick }: EnvelopeCardProps) { )}
- {envelope.received_at ? formatRelativeTime(envelope.received_at) : "—"} + {formatRelativeTime(((md?.created_at as string) || envelope.received_at || "")) || "—"}
diff --git a/loom/webui/frontend/src/components/KanbanBoard.tsx b/loom/webui/frontend/src/components/KanbanBoard.tsx index 3b59e62..7bf8795 100644 --- a/loom/webui/frontend/src/components/KanbanBoard.tsx +++ b/loom/webui/frontend/src/components/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react" +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import type { Envelope, EnvelopeStatus } from "@/lib/types" import { cn } from "@/lib/utils" @@ -12,6 +12,10 @@ const COLUMN_DEFS = [ { title: "Done", status: "done" as const }, ] as const +const ANIM_MS = 300 +const PAD = 16 // container px-4 +const GAP = 12 + interface KanbanBoardProps { envelopes: Envelope[] showArchived: boolean @@ -44,30 +48,104 @@ export function KanbanBoard({ ? [...grouped.done, ...grouped.dismissed, ...grouped.failed] : grouped.done - // Freeze the active column status when a card is first selected so that - // envelope status changes (via polling) don't cause layout jumps. - const [frozenStatus, setFrozenStatus] = useState(null) - const prevSelectedId = useRef(null) + const isOpen = selectedId !== null + + const activeStatus = useMemo(() => { + if (!selectedId) return null + const s = envelopes.find((e) => e.id === selectedId)?.status ?? null + if (s === "dismissed" || s === "failed") return "done" as const + return s + }, [selectedId, envelopes]) + + const containerRef = useRef(null) + const colRefs = useRef>>({}) + // Captured before the DOM update so panel starts at the column's true right edge. + const pendingPanelLeftRef = useRef(null) + + const [frozenStatus, setFrozenStatus] = useState(null) + const [frozenWidth, setFrozenWidth] = useState(null) + const [panelLeft, setPanelLeft] = useState(null) + // Suppresses column transitions for one frame on close. + const [isSnapping, setIsSnapping] = useState(false) + // Suppresses the panel's left transition while it snaps to its start position. + const [isPanelSnapping, setIsPanelSnapping] = useState(false) + + const closeTimer = useRef | null>(null) + const animRaf = useRef(null) + + useLayoutEffect(() => { + if (closeTimer.current) clearTimeout(closeTimer.current) + if (animRaf.current) cancelAnimationFrame(animRaf.current) - if (selectedId !== prevSelectedId.current) { - prevSelectedId.current = selectedId if (selectedId) { const env = envelopes.find((e) => e.id === selectedId) - if (env) setFrozenStatus(env.status) + if (env && containerRef.current) { + const mapped = + env.status === "dismissed" || env.status === "failed" ? "done" : env.status + const colEl = colRefs.current[mapped] + if (colEl) { + const cRect = containerRef.current.getBoundingClientRect() + const colWidth = Math.round((cRect.width - 2 * PAD) / 4) + const finalPanelLeft = PAD + colWidth + GAP + + setIsSnapping(false) + setFrozenStatus(mapped) + setFrozenWidth(colWidth) + + const initialPanelLeft = pendingPanelLeftRef.current ?? finalPanelLeft + + if (initialPanelLeft !== finalPanelLeft) { + // Panel snaps instantly to the column's original right edge (no + // left transition), then animates left in sync with the columns. + setIsPanelSnapping(true) + setPanelLeft(initialPanelLeft) + animRaf.current = requestAnimationFrame(() => { + setIsPanelSnapping(false) + setPanelLeft(finalPanelLeft) + }) + } else { + setIsPanelSnapping(false) + setPanelLeft(finalPanelLeft) + } + } + } } else { + // Snap columns back instantly (covered by the still-opaque panel). + setIsSnapping(true) setFrozenStatus(null) + setFrozenWidth(null) + animRaf.current = requestAnimationFrame(() => setIsSnapping(false)) + closeTimer.current = setTimeout(() => setPanelLeft(null), ANIM_MS) } - } + }, [selectedId]) // eslint-disable-line react-hooks/exhaustive-deps - const isOpen = selectedId !== null - const activeStatus = isOpen ? frozenStatus : null + useEffect(() => () => { + if (closeTimer.current) clearTimeout(closeTimer.current) + if (animRaf.current) cancelAnimationFrame(animRaf.current) + }, []) + + const effectiveStatus = frozenStatus ?? activeStatus - const orderedColumns = useMemo(() => { - if (!activeStatus) return COLUMN_DEFS - const active = COLUMN_DEFS.find((c) => c.status === activeStatus)! - const rest = COLUMN_DEFS.filter((c) => c.status !== activeStatus) - return [active, ...rest] - }, [activeStatus]) + // handleSelect captures the column's current right edge BEFORE the DOM + // update so the panel can start adjacent to the column's original position. + const handleSelect = (id: string | null) => { + if (id && containerRef.current) { + const env = envelopes.find((e) => e.id === id) + if (env) { + const mapped = + env.status === "dismissed" || env.status === "failed" ? "done" : env.status + const colEl = colRefs.current[mapped] + if (colEl) { + const cRect = containerRef.current.getBoundingClientRect() + const eRect = colEl.getBoundingClientRect() + pendingPanelLeftRef.current = Math.round(eRect.right - cRect.left) + GAP + } + } + } else { + pendingPanelLeftRef.current = null + } + onSelect(id) + } const columnData = useMemo( () => ({ @@ -80,26 +158,32 @@ export function KanbanBoard({ ) return ( -
- {orderedColumns.map((colDef, idx) => { - const isHidden = isOpen && idx > 0 +
+ {COLUMN_DEFS.map((colDef) => { + const isActive = effectiveStatus === colDef.status && frozenWidth != null + const isHidden = isOpen && effectiveStatus != null && effectiveStatus !== colDef.status return (
{ colRefs.current[colDef.status] = el }} className={cn( - "transition-all duration-300 ease-in-out", - isHidden - ? "w-0 min-w-0 flex-none overflow-hidden opacity-0" - : "flex h-full min-w-[260px] flex-1 flex-col rounded-lg p-2" + "flex-none h-full flex-col overflow-hidden rounded-lg p-2", + isHidden && "opacity-0 pointer-events-none select-none", )} - style={isHidden ? { marginLeft: 0, marginRight: 0 } : undefined} + style={{ + width: isActive ? frozenWidth! : isHidden ? 0 : "25%", + ...(isHidden ? { padding: 0 } : {}), + transition: isActive || isSnapping + ? "none" + : "width 300ms ease-in-out, padding 300ms ease-in-out, opacity 300ms ease-in-out", + }} >
) @@ -107,13 +191,19 @@ export function KanbanBoard({
- {isOpen && } + {(isOpen || frozenStatus != null) && ( + handleSelect(null)} /> + )}
) diff --git a/loom/webui/frontend/src/lib/utils.ts b/loom/webui/frontend/src/lib/utils.ts index 4ade6cf..d6f0950 100644 --- a/loom/webui/frontend/src/lib/utils.ts +++ b/loom/webui/frontend/src/lib/utils.ts @@ -6,7 +6,9 @@ export function cn(...inputs: ClassValue[]) { } export function formatRelativeTime(iso: string): string { + if (!iso) return "" const date = new Date(iso) + if (isNaN(date.getTime())) return "" const diffMs = Date.now() - date.getTime() const diffMin = Math.floor(diffMs / 60000) if (diffMin < 1) return "just now"