Recover from a dead session, and report a node problem once

An expired or orphaned session left the app on a half-rendered page instead of
the login screen: a properly signed token naming a user who no longer exists
answered 404, which the client does not treat as an authentication failure. All
failures in get_current_user are 401 now, and the client stops retrying them,
so a dead session goes straight back to the login screen.

- Clicking an edge names the two nodes it runs between, not just the message.
- A node with a problem shows one badge carrying the whole explanation, rather
  than a corner badge and a status dot saying the same thing twice. The dot is
  back to what it is good at: whether the node ran.
- The sidebar's collapse control sits in the sidebar, where a phone still finds
  one in the chrome because there is no sidebar on screen to hold it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i
This commit is contained in:
Melvin Strobl
2026-08-15 19:19:57 +02:00
co-authored by Claude Fable 5
parent 6fb42bb3ef
commit c254d487ba
9 changed files with 128 additions and 24 deletions
+16 -3
View File
@@ -1,3 +1,5 @@
import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
@@ -15,13 +17,15 @@ function relativeTime(ts: number | null | undefined): string {
export type InspectedEdge = {
message: string
/** Node titles, so the popover names the two ends in the user's own words. */
from: string
to: string
x: number
y: number
}
/**
* What last travelled along an edge. The value is whatever the producing node
* published, shown as it was serialised.
* What last travelled along an edge, and between which two nodes.
*/
export function EdgeInspector({
edge,
@@ -48,7 +52,16 @@ export function EdgeInspector({
className="w-72 p-3"
data-testid="edge-inspector"
>
<p className="font-mono text-xs text-muted-foreground">
<p className="flex items-center gap-1.5 text-sm">
<span className="min-w-0 flex-1 truncate font-medium">
{edge.from}
</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-right font-medium">
{edge.to}
</span>
</p>
<p className="mt-1 font-mono text-xs text-muted-foreground">
{displayName(flow, edge.message)}
</p>
+6 -1
View File
@@ -166,7 +166,6 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
flow: flowName,
typeLabel:
typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "",
issues: nodeIssues.length,
issueText: nodeIssues.join("\n"),
} satisfies FlowNodeData,
}
@@ -413,8 +412,14 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
setInspected(null)
}}
onEdgeClick={(event, edge) => {
const label = (id: string) => {
const node = definitions.find((entry) => entry.id === id)
return node?.title || node?.id || id
}
setInspected({
message: (edge.data as { message: string }).message,
from: label(edge.source),
to: label(edge.target),
x: event.clientX,
y: event.clientY,
})
+20 -11
View File
@@ -29,17 +29,17 @@ const NODE_ICONS = {
mlp: Braces,
} as const
// Only the states worth a quiet marker. Anything wrong goes to the badge
// instead, so a problem is never reported twice on the same node.
const STATUS_STYLES = {
running: { dot: "bg-primary animate-pulse", label: "Running" },
success: { dot: "bg-status-success", label: "Last run succeeded" },
error: { dot: "bg-destructive", label: "Failed" },
} as const
export type FlowNodeData = {
definition: NodeDef_Input
flow: string
typeLabel: string
issues: number
issueText: string
[key: string]: unknown
}
@@ -83,15 +83,18 @@ function PortHandles({
}
function FlowNodeComponent({ data, selected }: NodeProps) {
const { definition, flow, typeLabel, issues, issueText } =
data as FlowNodeData
const { definition, flow, typeLabel, issueText } = data as FlowNodeData
const live = useNodeStatus(`${flow}.${definition.id}`)
const Icon = NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ?? Code2
const status = live?.status === "active" ? undefined : live?.status
const style = status
? STATUS_STYLES[status as keyof typeof STATUS_STYLES]
: undefined
// Whatever is wrong — it failed to load, it failed to run, or the graph
// around it does not add up — is one badge with one explanation.
const problem = [live?.status === "error" ? live.error : null, issueText]
.filter(Boolean)
.join("\n")
const style = problem
? undefined
: STATUS_STYLES[live?.status as keyof typeof STATUS_STYLES]
return (
<div
@@ -132,14 +135,20 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
) : null}
</div>
{issues > 0 ? (
{problem ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="absolute -right-1.5 -top-1.5 flex size-4 items-center justify-center rounded-full bg-destructive text-primary-foreground">
<span
role="img"
aria-label="This node has a problem"
className="absolute -right-1.5 -top-1.5 flex size-4 items-center justify-center rounded-full bg-destructive text-primary-foreground"
>
<AlertCircle className="size-3" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{issueText}</TooltipContent>
<TooltipContent className="max-w-xs whitespace-pre-line">
{problem}
</TooltipContent>
</Tooltip>
) : null}
+1 -1
View File
@@ -131,7 +131,7 @@ export function FlowTabs({
transition={transitions.emphasized}
className="pointer-events-auto absolute left-1/2 top-4 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md"
>
<SidebarTrigger className="size-8 shrink-0 text-muted-foreground" />
<SidebarTrigger className="size-8 shrink-0 text-muted-foreground md:hidden" />
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{flows.map((flow) => (
+10 -1
View File
@@ -7,6 +7,7 @@ import {
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarTrigger,
} from "@/components/ui/sidebar"
import useAuth from "@/hooks/useAuth"
import { type Item, Main } from "./Main"
@@ -33,7 +34,15 @@ export function AppSidebar() {
className="[&>[data-sidebar=sidebar]]:bg-card/80 [&>[data-sidebar=sidebar]]:backdrop-blur-md [&>[data-sidebar=sidebar]]:shadow-e2"
>
<SidebarHeader className="px-4 py-6 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center">
<Logo variant="responsive" />
<div className="flex w-full items-center justify-between gap-2 group-data-[collapsible=icon]:justify-center">
{/* Collapsed, the rail has room for one thing, and that is the way
back out. */}
<span className="group-data-[collapsible=icon]:hidden">
<Logo variant="responsive" />
</span>
{/* On a phone the sidebar is a sheet with its own way in and out. */}
<SidebarTrigger className="hidden shrink-0 text-muted-foreground md:inline-flex" />
</div>
</SidebarHeader>
<SidebarContent>
<Main items={items} />
+15 -1
View File
@@ -19,12 +19,17 @@ OpenAPI.TOKEN = async () => {
return localStorage.getItem("access_token") || ""
}
/** A session the server will not accept, whatever we do next. */
const isAuthFailure = (error: unknown) =>
error instanceof ApiError && [401, 403].includes(error.status)
const handleApiError = (error: Error) => {
if (error instanceof ApiError && [401, 403].includes(error.status)) {
if (isAuthFailure(error)) {
localStorage.removeItem("access_token")
window.location.href = "/login"
}
}
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: handleApiError,
@@ -32,6 +37,15 @@ const queryClient = new QueryClient({
mutationCache: new MutationCache({
onError: handleApiError,
}),
defaultOptions: {
queries: {
// Retrying an expired session only delays the trip to the login screen.
retry: (count, error) => !isAuthFailure(error) && count < 3,
},
mutations: {
retry: false,
},
},
})
const router = createRouter({ routeTree })
+3 -1
View File
@@ -25,7 +25,9 @@ function Layout() {
<SidebarProvider className="bg-card">
<AppSidebar />
<SidebarInset className="bg-card">
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b px-4">
{/* The sidebar carries its own collapse control; a phone has no
sidebar on screen to carry it. */}
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 px-4 md:hidden">
<SidebarTrigger className="-ml-1 text-muted-foreground" />
</header>
<main className="flex-1 p-6 md:p-8">