// docs/SIDEBAR.md: a session card is always two lines — the name, then the // adaptive meta line — with the activity rail in the gutter beside them. import { useEffect, useRef, type MouseEvent } from "react"; import { activityDurationLabel, activityHeldMs, META_LINE_MIN_HOLD_MS, } from "../../activity.js"; import { splitCommand, toolIconSvg } from "../../toolIcon.js"; import type { InTheWay, Session } from "../../types.js"; import { blockerStats, customSessionName, formatInTheWay, isProvisionalName, MAX_SESSION_NAME_LEN, normalizeSessionName, sessionBlockers, sessionName, } from "../../types.js"; import { ActivityRail, DURATION_CLASS, stateWordFor } from "./ActivityRail.js"; import { activityColorSlug } from "../../activity.js"; export interface SessionCardProps { session: Session; /** Nesting depth; the row's padding (and so the rail) shifts outward with it. */ depth: number; /** Clock the duration is measured against — the sidebar ticks it. */ now: number; highlighted: boolean; /** Showing in the focused pane. */ active: boolean; /** Open in some pane, not the focused one. */ open: boolean; previewing: boolean; renaming: boolean; onSelect(): void; onOpenMenu(x: number, y: number): void; onSpawnSimilar(): void; onKill(): void; onRenameCommit(name: string): void; onRenameCancel(): void; } /** The fill ladder from docs/SIDEBAR.md "Which row is filled", furthest down * wins. Written as one ordered run so the precedence is the order it reads in. */ function rowFill(p: SessionCardProps): string { if (p.active) return "bg-accent-fill"; if (p.previewing) return "bg-preview-tint"; if (p.open) return "bg-accent/[0.06]"; if (p.highlighted) return "bg-preview-tint"; return ""; } export function SessionCard(props: SessionCardProps) { const { session: s, depth, now, renaming } = props; const name = sessionName(s); const running = s.status === "Running"; const word = stateWordFor(s.activity); return ( { if (renaming) return; e.preventDefault(); props.onSelect(); }} onContextMenu={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); props.onOpenMenu(e.clientX, e.clientY); }} > {renaming ? ( ) : ( {name} )} ); } /** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`. * The line itself is unconditional; only its fragments are adaptive. */ export function MetaLine({ session: s, now }: { session: Session; now: number }) { const slug = activityColorSlug(s.activity ?? "Idle"); const held = activityHeldMs(s.activity_since, now); const duration = held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null; const blockers = sessionBlockers(s); return ( {duration && ( {duration} )} {s.activity_detail && ( {s.activity_detail} )} {blockers && } {s.status === "Dead" && exited} ); } /** docs/SIDEBAR.md: the card names the program only, as its tool mark when we * have one. The full command line stays in the tooltip either way. */ function CommandFragment({ command }: { command: string }) { const { tool, program } = splitCommand(command); const icon = tool ? toolIconSvg(tool) : null; return ( {icon ? ( ) : ( {program} )} ); } /** Blocked work in diff notation — `+124 -18 ?3 ↑2`. Only `+` and `-` take * colour; the spelled-out breakdown survives as the tooltip. */ function BlockedFragment({ blockers }: { blockers: InTheWay }) { const stats = blockerStats(blockers); return ( {stats.map((st) => ( {st.text} ))} ); } /** Inline rename (#125): Enter commits, Escape and blur cancel, an emptied * field clears the name so the command fallback resumes. It starts from the * session's own name, never from the command it falls back to. */ function RenameField({ session: s, onCommit, onCancel, }: { session: Session; onCommit(name: string): void; onCancel(): void; }) { const ref = useRef(null); const settled = useRef(false); useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []); return ( the tree also drags from: without these, clicking // into the field navigates away or starts a drag. onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); if (settled.current) return; settled.current = true; onCommit(normalizeSessionName(e.currentTarget.value)); } else if (e.key === "Escape") { e.preventDefault(); if (settled.current) return; settled.current = true; onCancel(); } }} onBlur={() => { if (settled.current) return; settled.current = true; onCancel(); }} /> ); }