Skip to content
Merged
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
116 changes: 91 additions & 25 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<boolean>("skipIdle", true);
const [autoscroll, setAutoscroll] = usePersistent<boolean>("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<string>; epoch: number }>({ ids: new Set(), epoch: 0 });
const collapsedIds = collapsed.epoch === tape.epoch ? collapsed.ids : new Set<string>();
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(() => {
Expand Down Expand Up @@ -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]);
Expand All @@ -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]);
Expand All @@ -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(
Expand Down Expand Up @@ -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<HTMLElement>(".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 ---------------------------------------------------------------------
Expand All @@ -364,14 +398,15 @@ 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).
e.stopPropagation();
};
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;
Expand Down Expand Up @@ -424,6 +459,16 @@ export default function App({ name: tapeName, onNavigate }: { name: string; onNa
{tape.info?.size != null && <span>{bytes(tape.info.size)}</span>}
</div>
</div>
<button
className="icon-btn square layout-btn"
data-active={!single || undefined}
onClick={() => setLayout(single ? "split" : "single")}
title={single ? "Show the turn list (\\)" : "One column: all turns in full (\\)"}
aria-label="Turn list"
aria-pressed={!single}
>
<Sidebar />
</button>
<button
className="icon-btn square search-btn"
data-active={searchOpen || undefined}
Expand Down Expand Up @@ -470,28 +515,49 @@ export default function App({ name: tapeName, onNavigate }: { name: string; onNa
</div>
<hr />
<div className="hint">
<kbd>/</kbd> search · <kbd>Space</kbd> play/pause · <kbd>←</kbd>/<kbd>→</kbd> step · <kbd>⇧</kbd>+arrows turn · <kbd>J</kbd>/<kbd>K</kbd> turn list · <kbd>[</kbd>/<kbd>]</kbd> anchor · <kbd>L</kbd> live on/off
<kbd>/</kbd> search · <kbd>Space</kbd> play/pause · <kbd>←</kbd>/<kbd>→</kbd> step · <kbd>⇧</kbd>+arrows turn · <kbd>J</kbd>/<kbd>K</kbd> turns · <kbd>{"\\"}</kbd> layout · <kbd>[</kbd>/<kbd>]</kbd> anchor · <kbd>L</kbd> live on/off
</div>
</SettingsPopover>
</header>

<div className={`content ${searchOpen ? "with-search" : ""}`}>
<div className={`panes ${showDetail ? "show-detail" : ""}`}>
<aside className={`list-pane ${anchors.length ? "has-head" : ""}`} ref={listRef}>
{anchors.length > 0 && (
<div className="list-head">
<AnchorPicker anchors={anchors} passed={(a) => mode === "replay" && a.i < shown} onPick={goAnchor} />
</div>
)}
<TurnList items={tl.items} opts={viewOpts} selected={selected?.id ?? null} onSelect={pick} version={tl.version} />
</aside>
<div className={`panes ${single ? "single" : showDetail ? "show-detail" : ""}`}>
{!single && (
<aside className={`list-pane ${anchors.length ? "has-head" : ""}`} ref={listRef}>
{anchors.length > 0 && (
<div className="list-head">
<AnchorPicker anchors={anchors} passed={(a) => mode === "replay" && a.i < shown} onPick={goAnchor} />
</div>
)}
<TurnList items={tl.items} opts={viewOpts} selected={selected?.id ?? null} onSelect={pick} version={tl.version} />
</aside>
)}
<section className="detail-pane timeline-wrap" ref={scroller} onScroll={onScroll}>
<button className="back" onClick={() => setShowDetail(false)}>
<ChevronLeft size={14} />
Turns
</button>
{single ? (
tl.items.length > 0 && (
<div className="stream-head">
{turnIds.length > 1 && (
<button
className="btn fold-all"
onClick={() => setCollapsed({ ids: new Set(allCollapsed ? [] : turnIds), epoch: tape.epoch })}
>
<ChevronRight size={14} className={allCollapsed ? "" : "open"} />
{allCollapsed ? "Expand all" : "Collapse all"}
</button>
)}
{anchors.length > 0 && <AnchorPicker anchors={anchors} passed={(a) => mode === "replay" && a.i < shown} onPick={goAnchor} />}
</div>
)
) : (
<button className="back" onClick={() => setShowDetail(false)}>
<ChevronLeft size={14} />
Turns
</button>
)}
<div className="detail">
<ItemDetail item={selected} v={selected?.v ?? 0} opts={viewOpts} />
{single ? (
<Stream items={tl.items} opts={viewOpts} version={tl.version} collapsed={collapsedIds} onToggle={toggleTurn} />
) : <ItemDetail item={selected} v={selected?.v ?? 0} opts={viewOpts} />}
</div>
{!tl.items.length && tape.connection !== "loading" && (
<div className="empty">
Expand Down
64 changes: 57 additions & 7 deletions web/src/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
onToggle: (id: string) => void;
}) {
return (
<div className="stream" data-version={version}>
{items.map((item) =>
item.type === "misc" && item.kind === "event" && !opts.events ? null : (
<StreamItem key={item.id} item={item} v={item.v} opts={opts} collapsed={collapsed.has(item.id)} onToggle={onToggle} />
),
)}
</div>
);
}

/** 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 <TurnDetail turn={item} opts={opts} />;
if (item.type === "turn") return <TurnDetail turn={item} opts={opts} collapsed={collapsed} onToggle={onToggle} />;
return (
<article className="detail-misc" id={item.id} data-entry={item.i0}>
<div className="detail-time">
Expand All @@ -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 || <span className="muted">(no prompt)</span>;
return (
<article className={`turn-detail ${turn.status}`} id={turn.id} data-entry={turn.i0}>
<article className={`turn-detail ${turn.status} ${collapsed ? "collapsed" : ""}`} id={turn.id} data-entry={turn.i0}>
<header className="turn-head">
<div className="detail-time">
{dayOf(turn.t0)} {timeOfDay(turn.t0)}
{turn.t1 != null && !running && ` – ${timeOfDay(turn.t1)}`}
</div>
<h2 className="prompt">{prompt.headline || <span className="muted">(no prompt)</span>}</h2>
{onToggle ? (
<h2 className="prompt">
<button className="turn-toggle" onClick={() => onToggle(turn.id)} aria-expanded={!collapsed} title={collapsed ? "Expand turn" : "Collapse turn"}>
<ChevronRight className="disc" size={16} />
<span>{headline}</span>
</button>
</h2>
) : (
<h2 className="prompt">{headline}</h2>
)}
<div className="meta">
{turn.status !== "ok" && <span className={`state ${turn.status}`}>{turn.status}</span>}
{turn.model && <span className="mono">{turn.model}</span>}
Expand All @@ -167,8 +215,9 @@ function TurnDetail({ turn, opts }: { turn: Turn; opts: ViewOptions }) {
{turn.error}
</div>
)}
{running ? <LiveWaterfall turn={turn} /> : <Waterfall turn={turn} now={turn.t1 ?? turn.t0} />}
{collapsed ? null : running ? <LiveWaterfall turn={turn} /> : <Waterfall turn={turn} now={turn.t1 ?? turn.t0} />}
</header>
{!collapsed && (
<div className="steps">
{prompt.body !== prompt.headline || prompt.body.length > 600 ? (
<details className="block sys full-prompt">
Expand All @@ -193,6 +242,7 @@ function TurnDetail({ turn, opts }: { turn: Turn; opts: ViewOptions }) {
</div>
)}
</div>
)}
</article>
);
}
Expand Down
8 changes: 8 additions & 0 deletions web/src/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ export const Settings = (p: IconProps) => (
</Icon>
);

/** A window with a sidebar: the two-pane layout (turn list beside the selected turn). */
export const Sidebar = (p: IconProps) => (
<Icon {...p}>
<rect {...ns} x="2" y="3" width="12" height="10" rx="2" />
<path {...ns} d="M6.5 3v10" />
</Icon>
);

export const Search = (p: IconProps) => (
<Icon {...p}>
<circle {...ns} cx="7" cy="7" r="4.25" />
Expand Down
Loading
Loading