diff --git a/README.md b/README.md index 27b0fd0..b09a322 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,11 @@ The page has two panes (on narrow screens, the panes take turns with a back butt - **Turn selection**: - Follows the latest turn by default: the one being written when live, or the one at the playhead when replaying. - Click an older turn to stay on it; click the newest one, press **↓ Latest**, or press play to follow again. +- **One column**: the sidebar button in the top bar (or `\`) hides the turn list and shows every turn in full, one after another, for reading a tape top to bottom. Click a turn's title to fold it to its header, or use **Collapse all** / **Expand all**; jumping to an anchor or a search result unfolds the turn it lands in. The layout choice is remembered. ## Anchors -- Listed in the **Anchors** picker at the top of the turn list and shown as purple ticks on the track. +- Listed in the **Anchors** picker at the top of the turn list (top right in one column) and shown as purple ticks on the track. - Picking an anchor selects the turn that contains it and scrolls to it. - In replay, picking an anchor also seeks there and pauses, so playback continues from that anchor. @@ -79,9 +80,10 @@ Without a tape argument, the page lists every tape in Bub's tape directory, refr | `←` / `→` | Previous / next step | | `⇧←` / `⇧→` | Previous / next turn | | `Home` / `End` | Jump to the start / end (replay) | -| `J` / `K` | Next / previous turn in the list | +| `J` / `K` | Next / previous turn | | `[` / `]` | Previous / next anchor | | `L` | Live on / off | +| `\` | One column / turn list | ## Development diff --git a/web/src/App.tsx b/web/src/App.tsx index 16fa499..f0e7d4d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { ItemDetail, TurnList, type Focus, type ViewOptions } from "./components/Timeline"; +import { ItemDetail, Stream, TurnList, type Focus, type ViewOptions } from "./components/Timeline"; import { AnchorPicker, CheckField, Segmented, SettingsPopover, SpeedSelect } from "./components/controls"; import { Toggle } from "@base-ui/react/toggle"; import { SearchPanel } from "./components/Search"; import { Brand, TapeCrumbs } from "./components/Tapes"; -import { ArrowDown, ChevronLeft, ChevronRight, Pause, Play, Search as SearchIcon, SkipEnd, SkipStart } from "./components/icons"; +import { ArrowDown, ChevronLeft, ChevronRight, Pause, Play, Search as SearchIcon, Sidebar, SkipEnd, SkipStart } from "./components/icons"; import { bytes, clock, duration, timeOfDay, tokens } from "./format"; import { NowContext, usePersistent, useTape, useTicker } from "./hooks"; import { @@ -50,6 +50,26 @@ export default function App({ name: tapeName, onNavigate }: { name: string; onNa const speed = SPEEDS.includes(storedSpeed) ? storedSpeed : 4; // a speed saved before the list changed const [skipIdle, setSkipIdle] = usePersistent("skipIdle", true); const [autoscroll, setAutoscroll] = usePersistent("autoscroll", true); + // "split": turn list | selected turn; "single": every turn in full in one column. + const [layout, setLayout] = usePersistent<"split" | "single">("layout", "split"); + const single = layout === "single"; + // Turns collapsed in one column (new turns arrive expanded). Item ids restart when the tape is reset. + const [collapsed, setCollapsed] = useState<{ ids: ReadonlySet; epoch: number }>({ ids: new Set(), epoch: 0 }); + const collapsedIds = collapsed.epoch === tape.epoch ? collapsed.ids : new Set(); + const toggleTurn = useCallback( + (id: string) => + setCollapsed((c) => { + const ids = new Set(c.epoch === tape.epoch ? c.ids : []); + if (!ids.delete(id)) ids.add(id); + return { ids, epoch: tape.epoch }; + }), + [tape.epoch], + ); + const expand = useCallback( + (id: string | undefined) => + id && setCollapsed((c) => (c.ids.has(id) ? { ids: new Set([...c.ids].filter((x) => x !== id)), epoch: c.epoch } : c)), + [], + ); const viewOpts = useMemo(() => ({ ...DEFAULT_OPTS, ...opts }), [opts]); useEffect(() => { @@ -198,16 +218,18 @@ export default function App({ name: tapeName, onNavigate }: { name: string; onNa else if (tl.count) setBehind(true); }, [tl.version, follow]); // eslint-disable-line react-hooks/exhaustive-deps // Another item selected: a followed turn opens at its newest step, a picked one at its top. - // (A focus request scrolls on its own, see below.) + // In one column every item is there already: scroll to the picked one. (A focus request scrolls on its own, see below.) const selectedId = selected?.id; useLayoutEffect(() => { const el = scroller.current; if (!el || sel.kind === "entry") return; stick.current = following; setBehind(false); - el.scrollTop = following ? el.scrollHeight : 0; + if (following) el.scrollTop = el.scrollHeight; + else if (!single) el.scrollTop = 0; + else el.querySelector(`[id="${CSS.escape(selectedId ?? "")}"]`)?.scrollIntoView({ block: "start" }); listRef.current?.querySelector(`[data-item="${selectedId}"]`)?.scrollIntoView({ block: "nearest" }); - }, [selectedId, following]); // eslint-disable-line react-hooks/exhaustive-deps + }, [selectedId, following, single]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (sel.kind === "entry") listRef.current?.querySelector(`[data-item="${selectedId}"]`)?.scrollIntoView({ block: "nearest" }); }, [selectedId, sel]); @@ -221,10 +243,14 @@ export default function App({ name: tapeName, onNavigate }: { name: string; onNa (item: Item) => { setSel(item.id === latest?.id ? { kind: "follow" } : { kind: "item", id: item.id }); setShowDetail(true); + expand(item.id); }, - [latest], + [latest, expand], ); + const turnIds = useMemo(() => tl.items.filter((it) => it.type === "turn").map((it) => it.id), [tl, tl.version]); // eslint-disable-line react-hooks/exhaustive-deps + const allCollapsed = turnIds.length > 0 && turnIds.every((id) => collapsedIds.has(id)); + // -- anchors ---------------------------------------------------------------------- // eslint-disable-next-line react-hooks/exhaustive-deps const anchors = useMemo(() => listAnchors(entries), [tape.version, tape.epoch]); @@ -235,13 +261,14 @@ export default function App({ name: tapeName, onNavigate }: { name: string; onNa stick.current = false; // don't let follow-scrolling yank the view away setSel({ kind: "entry", i }); setShowDetail(true); + expand(resolveSelection(tl.items, { kind: "entry", i }, null)?.id); // in one column the turn may be collapsed if (mode === "replay") { setPlaying(false); seekIndex(i); } setFocus((f) => ({ i, n: (f?.n ?? 0) + 1, ...extra })); }, - [mode, seekIndex], + [mode, seekIndex, tl, expand], ); const goAnchor = useCallback((i: number) => goEntry(i), [goEntry]); const stepAnchor = useCallback( @@ -334,11 +361,18 @@ export default function App({ name: tapeName, onNavigate }: { name: string; onNa const stepItem = useCallback( (dir: 1 | -1) => { const turns = tl.items.filter((it) => it.type === "turn"); - const at = turns.findIndex((t) => t.id === selected?.id); + let current = selected?.id; + if (single && scroller.current) { + // In one column the reader scrolls freely: step from the turn at the top of the view. + const top = scroller.current.getBoundingClientRect().top + 40; // a turn scrolled to sits just below the top + const shown = [...scroller.current.querySelectorAll(".stream > .turn-detail")]; + current = shown.findLast((el) => el.getBoundingClientRect().top <= top)?.id ?? (dir > 0 ? undefined : shown[0]?.id); + } + const at = turns.findIndex((t) => t.id === current); const next = turns[at < 0 ? (dir > 0 ? 0 : turns.length - 1) : at + dir]; if (next) pick(next); }, - [tl, selected, pick], + [tl, selected, pick, single], ); // -- keyboard --------------------------------------------------------------------- @@ -364,6 +398,7 @@ export default function App({ name: tapeName, onNavigate }: { name: string; onNa else if (key === "]") stepAnchor(1); else if (key === "[") stepAnchor(-1); else if (key === "l" || key === "L") setMode(mode === "live" ? "replay" : "live"); + else if (key === "\\") setLayout(single ? "split" : "single"); else return; e.preventDefault(); // Handled shortcuts win over focused widgets (e.g. arrow-key focus moves in the mode toggle group). @@ -371,7 +406,7 @@ export default function App({ name: tapeName, onNavigate }: { name: string; onNa }; window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); - }, [togglePlay, jump, stepAnchor, stepItem, mode, total, setMode, openSearch]); + }, [togglePlay, jump, stepAnchor, stepItem, mode, total, setMode, openSearch, single, setLayout]); // -- derived view bits -------------------------------------------------------------- const turns = tl.turns; @@ -424,6 +459,16 @@ export default function App({ name: tapeName, onNavigate }: { name: string; onNa {tape.info?.size != null && {bytes(tape.info.size)}} + + {single ? ( + tl.items.length > 0 && ( +
+ {turnIds.length > 1 && ( + + )} + {anchors.length > 0 && mode === "replay" && a.i < shown} onPick={goAnchor} />} +
+ ) + ) : ( + + )}
- + {single ? ( + + ) : }
{!tl.items.length && tape.connection !== "loading" && (
diff --git a/web/src/components/Timeline.tsx b/web/src/components/Timeline.tsx index 6468ca8..768d965 100644 --- a/web/src/components/Timeline.tsx +++ b/web/src/components/Timeline.tsx @@ -111,10 +111,48 @@ const ListRow = memo(function ListRow({ item, active, onSelect }: { item: Item; ); }); -/** Right pane: the selected item in full. */ -export function ItemDetail({ item, opts }: { item: Item | null; v: number; opts: ViewOptions }) { +const StreamItem = memo(ItemDetail); + +/** Single-column layout: every item one after another, turns collapsible (re-rendering only items that changed). */ +export function Stream({ + items, + opts, + version, + collapsed, + onToggle, +}: { + items: Item[]; + opts: ViewOptions; + version: number; + collapsed: ReadonlySet; + onToggle: (id: string) => void; +}) { + return ( +
+ {items.map((item) => + item.type === "misc" && item.kind === "event" && !opts.events ? null : ( + + ), + )} +
+ ); +} + +/** Right pane: the selected item in full. In one column, a turn can be collapsed to its header (`onToggle`). */ +export function ItemDetail({ + item, + opts, + collapsed = false, + onToggle, +}: { + item: Item | null; + v: number; + opts: ViewOptions; + collapsed?: boolean; + onToggle?: (id: string) => void; +}) { if (!item) return null; - if (item.type === "turn") return ; + if (item.type === "turn") return ; return (
@@ -131,18 +169,28 @@ export function ItemDetail({ item, opts }: { item: Item | null; v: number; opts: ); } -function TurnDetail({ turn, opts }: { turn: Turn; opts: ViewOptions }) { +function TurnDetail({ turn, opts, collapsed, onToggle }: { turn: Turn; opts: ViewOptions; collapsed: boolean; onToggle?: (id: string) => void }) { const prompt = parsePrompt(turn.prompt); const running = turn.status === "running"; const lastStep = turn.steps[turn.steps.length - 1]; + const headline = prompt.headline || (no prompt); return ( -
+
{dayOf(turn.t0)} {timeOfDay(turn.t0)} {turn.t1 != null && !running && ` – ${timeOfDay(turn.t1)}`}
-

{prompt.headline || (no prompt)}

+ {onToggle ? ( +

+ +

+ ) : ( +

{headline}

+ )}
{turn.status !== "ok" && {turn.status}} {turn.model && {turn.model}} @@ -167,8 +215,9 @@ function TurnDetail({ turn, opts }: { turn: Turn; opts: ViewOptions }) { {turn.error}
)} - {running ? : } + {collapsed ? null : running ? : }
+ {!collapsed && (
{prompt.body !== prompt.headline || prompt.body.length > 600 ? (
@@ -193,6 +242,7 @@ function TurnDetail({ turn, opts }: { turn: Turn; opts: ViewOptions }) {
)}
+ )}
); } diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx index bf29db1..c48628c 100644 --- a/web/src/components/icons.tsx +++ b/web/src/components/icons.tsx @@ -115,6 +115,14 @@ export const Settings = (p: IconProps) => ( ); +/** A window with a sidebar: the two-pane layout (turn list beside the selected turn). */ +export const Sidebar = (p: IconProps) => ( + + + + +); + export const Search = (p: IconProps) => ( diff --git a/web/src/styles.css b/web/src/styles.css index 9150b4c..53a559f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -223,6 +223,20 @@ kbd { font-family: var(--mono); font-size: 12px; line-height: 16px; border: 1px .detail-pane { flex: 1; min-width: 0; } .detail { max-width: 980px; margin: 0 auto; padding: 20px 28px 120px; } .back { display: none; } +/* one column: every item in full, separated by a rule; the anchor picker sticks above them */ +.stream > * + * { margin-top: 28px; padding-top: 24px; border-top: 1px solid var(--line); } +.stream-head { position: sticky; top: 0; z-index: 3; display: flex; justify-content: flex-end; max-width: 980px; margin: 0 auto; padding: 10px 28px 0; pointer-events: none; } +.stream-head { gap: 8px; } +.stream-head > * { pointer-events: auto; box-shadow: var(--pop-shadow); background: var(--panel); } +.fold-all { gap: 6px; } +.fold-all .icon { transition: transform .15s; } +.fold-all .icon.open { transform: rotate(90deg); } +/* a turn header that folds the turn (one column) */ +.turn-toggle { display: flex; align-items: flex-start; gap: 6px; width: 100%; margin: 0 0 0 -22px; padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; } +.turn-toggle .disc { flex: none; margin-top: 5px; color: var(--faint); transform: rotate(90deg); transition: transform .15s; } +.turn-toggle:hover .disc { color: var(--text); } +.turn-detail.collapsed .turn-toggle .disc { transform: none; } +.turn-detail.collapsed .turn-head { padding-bottom: 0; } .turn-list { padding: 0 8px 40px; } /* anchor picker pinned at the top of the turn list; the day headers stick right below it */ @@ -555,7 +569,9 @@ div.tools > .tool + .tool { border-top: 1px solid var(--line); } .transport { flex-wrap: wrap; gap: 8px 12px; padding: 8px 12px; } /* one pane at a time: the list, or the selected item with a way back */ .list-pane { width: auto; flex: 1; border-right: 0; } - .panes.show-detail .list-pane, .panes:not(.show-detail) .detail-pane { display: none; } + .panes.show-detail .list-pane, .panes:not(.show-detail, .single) .detail-pane { display: none; } + .stream-head { padding: 8px 16px 0; } + .turn-toggle { margin-left: 0; } .back { display: inline-flex; align-items: center; gap: 4px; margin: 12px 0 0 8px; padding: 4px 8px; border: 0; border-radius: var(--r); background: none; color: var(--muted); font-size: 13px; cursor: pointer; } .back:hover { background: var(--hover); color: var(--text); } .detail { padding: 8px 16px 100px; }