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"