Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions loom/webui/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion loom/webui/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,23 +33,26 @@ export default function App() {
<div className="flex h-screen bg-background text-foreground">
<Sidebar
view={view}
onViewChange={setView}
onViewChange={(v) => { 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)
Expand Down
3 changes: 2 additions & 1 deletion loom/webui/frontend/src/components/EnvelopeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function pickLabelColors(env: Envelope): Record<string, string> {

/** Compact card shown inside a Kanban column. */
export function EnvelopeCard({ envelope, active, onClick }: EnvelopeCardProps) {
const md = envelope.metadata as Record<string, unknown> | 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(
Expand Down Expand Up @@ -51,7 +52,7 @@ export function EnvelopeCard({ envelope, active, onClick }: EnvelopeCardProps) {
)}
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{envelope.received_at ? formatRelativeTime(envelope.received_at) : "—"}
{formatRelativeTime(((md?.created_at as string) || envelope.received_at || "")) || "—"}
</span>
</div>
<div className="mt-1 line-clamp-2 text-sm font-medium leading-snug text-foreground">
Expand Down
152 changes: 121 additions & 31 deletions loom/webui/frontend/src/components/KanbanBoard.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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<EnvelopeStatus | null>(null)
const prevSelectedId = useRef<string | null>(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<HTMLDivElement | null>(null)
const colRefs = useRef<Partial<Record<string, HTMLDivElement | null>>>({})
// Captured before the DOM update so panel starts at the column's true right edge.
const pendingPanelLeftRef = useRef<number | null>(null)

const [frozenStatus, setFrozenStatus] = useState<string | null>(null)
const [frozenWidth, setFrozenWidth] = useState<number | null>(null)
const [panelLeft, setPanelLeft] = useState<number | null>(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<ReturnType<typeof setTimeout> | null>(null)
const animRaf = useRef<number | null>(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(
() => ({
Expand All @@ -80,40 +158,52 @@ export function KanbanBoard({
)

return (
<div className="flex h-full px-4 py-3">
{orderedColumns.map((colDef, idx) => {
const isHidden = isOpen && idx > 0
<div ref={containerRef} className="relative flex h-full overflow-hidden px-4 py-3">
{COLUMN_DEFS.map((colDef) => {
const isActive = effectiveStatus === colDef.status && frozenWidth != null
const isHidden = isOpen && effectiveStatus != null && effectiveStatus !== colDef.status
return (
<div
key={colDef.status}
ref={(el) => { 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",
}}
Comment on lines 160 to +179
>
<Column
title={colDef.title}
status={colDef.status}
envelopes={columnData[colDef.status]}
selectedId={selectedId}
onSelect={onSelect}
onSelect={handleSelect}
/>
</div>
)
})}

<div
className={cn(
"flex-none overflow-hidden rounded-lg border-l border-border transition-all duration-300 ease-in-out",
isOpen
? "ml-3 min-w-[480px] flex-[3] opacity-100"
: "min-w-0 w-0 flex-none opacity-0"
"absolute inset-y-3 right-0 overflow-hidden rounded-lg border-l border-border",
isOpen ? "opacity-100" : "opacity-0 pointer-events-none",
)}
style={{
left: panelLeft ?? "100%",
transition: isPanelSnapping
? "opacity 300ms ease-in-out"
: "opacity 300ms ease-in-out, left 300ms ease-in-out",
}}
>
{isOpen && <DetailPanel envelopeId={selectedId} onClose={onCloseDetail} />}
{(isOpen || frozenStatus != null) && (
<DetailPanel envelopeId={selectedId} onClose={() => handleSelect(null)} />
)}
Comment on lines 192 to +206
</div>
</div>
)
Expand Down
2 changes: 2 additions & 0 deletions loom/webui/frontend/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Comment on lines +9 to +11
const diffMs = Date.now() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return "just now"
Expand Down
Loading