pi-weave 0.3.8 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +17 -16
  2. package/package.json +4 -3
  3. package/skills/weave-explore/SKILL.md +3 -5
  4. package/skills/weave-notepad/SKILL.md +22 -2
  5. package/skills/weave-notepad/references/second-thoughts.md +57 -0
  6. package/src/core/graph/build.ts +1 -1
  7. package/src/core/graph/current.ts +10 -8
  8. package/src/core/history.ts +417 -0
  9. package/src/core/index.ts +19 -0
  10. package/src/core/links/repair.ts +5 -68
  11. package/src/core/noteAction.ts +72 -2
  12. package/src/core/openInEditor.ts +3 -8
  13. package/src/core/repoAction.ts +20 -61
  14. package/src/core/repoIndex.ts +38 -0
  15. package/src/core/trail.ts +124 -0
  16. package/src/core/types.ts +1 -3
  17. package/src/core/vault.ts +116 -29
  18. package/src/core/view/focus.ts +2 -2
  19. package/src/core/view/index.ts +2 -3
  20. package/src/core/view/rawTail.ts +153 -0
  21. package/src/core/workspace.ts +20 -26
  22. package/src/core/workspaceCommands.ts +14 -14
  23. package/src/opencode/tools.ts +3 -3
  24. package/src/opencode/tui.ts +1 -7
  25. package/src/pi/index.ts +68 -80
  26. package/src/pi/tools/noteTool.ts +1 -1
  27. package/src/pi/tools/repoTool.ts +3 -6
  28. package/src/pi/viewer/web/run.ts +6 -8
  29. package/src/web/client/api.ts +103 -5
  30. package/src/web/client/dist/app.js +183 -52
  31. package/src/web/client/graph/Graph.tsx +23 -2
  32. package/src/web/client/graph/column.model.ts +1 -1
  33. package/src/web/client/graph/graph.model.ts +69 -7
  34. package/src/web/client/graph/renderer.ts +19 -3
  35. package/src/web/client/note/Note.tsx +171 -20
  36. package/src/web/client/note/Replay.tsx +128 -0
  37. package/src/web/client/note/drafts.ts +50 -9
  38. package/src/web/client/note/note.model.ts +17 -0
  39. package/src/web/client/note/replay.model.ts +173 -0
  40. package/src/web/client/shell/ContextRail.tsx +7 -0
  41. package/src/web/client/shell/Settings.tsx +21 -1
  42. package/src/web/client/shell/Shell.tsx +34 -10
  43. package/src/web/client/shell/keys.model.ts +20 -7
  44. package/src/web/client/shell/theme.ts +118 -1
  45. package/src/web/client/shell/workspace.css.ts +4 -0
  46. package/src/web/client/trail/TrailStrip.tsx +55 -0
  47. package/src/web/client/trail/trail.model.ts +195 -0
  48. package/src/web/client/trail/trail.ts +45 -0
  49. package/src/web/client/tree/Tree.tsx +7 -3
  50. package/src/web/client/tree/tree.model.ts +2 -5
  51. package/src/web/server/page.ts +1 -1
  52. package/src/web/server/routes.ts +83 -3
  53. package/src/web/shared/logo.ts +1 -1
  54. package/src/web/shared/view.ts +10 -4
  55. package/src/web/shared/wire.ts +74 -1
  56. package/src/core/view/health.ts +0 -160
  57. package/src/pi/viewer/tui/bodyStore.ts +0 -95
  58. package/src/pi/viewer/tui/branding.ts +0 -14
  59. package/src/pi/viewer/tui/model.ts +0 -390
  60. package/src/pi/viewer/tui/openNote.ts +0 -11
  61. package/src/pi/viewer/tui/run.ts +0 -71
  62. package/src/pi/viewer/tui/surface/base.ts +0 -203
  63. package/src/pi/viewer/tui/surface/detail.ts +0 -315
  64. package/src/pi/viewer/tui/surface/explore.ts +0 -241
  65. package/src/pi/viewer/tui/surface/focus.ts +0 -139
  66. package/src/pi/viewer/tui/surface/health.ts +0 -102
  67. package/src/pi/viewer/tui/theme.ts +0 -90
  68. package/src/pi/viewer/tui/workspace.ts +0 -53
  69. package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
@@ -1,16 +1,16 @@
1
1
  /** Shared browser/desktop workspace: one data source, two optional tab groups. */
2
2
  import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "preact/hooks";
3
- import type { GraphPayload, NotePayload } from "../../shared/wire";
3
+ import type { GraphPayload, NotePayload, TrailPayload } from "../../shared/wire";
4
4
  import { activePane, activeTab, activateTab, closePane, closeTab, focusDocument, initialLayout, reorderTab, moveTab, navigateTab, openDocument, openWithPreferences, restoreWorkspace, openGraph, parseWorkspaceLayout, splitPane, tabDropSource, tabSelection } from "../../shared/workspace";
5
5
  import type { Pane, WorkspaceLayout, WorkspaceTab } from "../../shared/workspace";
6
6
  import { fetchJson } from "../api.dom";
7
- import { createNote, fetchGraph, openNote, saveNote } from "../api";
7
+ import { createNote, fetchGraph, fetchHistory, openNote, saveNote } from "../api";
8
8
  import { Graph } from "../graph/Graph";
9
9
  import { schemeOf, watchScheme } from "../graph/scheme";
10
10
  import { createSigmaRenderer } from "../graph/renderer.dom";
11
11
  import { Note } from "../note/Note";
12
12
  import { createDraftStore, mayMutateDrafts, mayRemoveDrafts } from "../note/drafts";
13
- import type { DraftStore } from "../note/drafts";
13
+ import type { DraftStore, SaveNote } from "../note/drafts";
14
14
  import { DISCARD_PROMPT } from "../note/note.model";
15
15
  import { SearchPalette } from "../search/SearchPalette";
16
16
  import { Icon, Tree } from "../tree/Tree";
@@ -18,6 +18,9 @@ import { initialWorkspaceState } from "../state";
18
18
  import { startWorkspace, watchNote } from "../workspace";
19
19
  import type { WorkspaceHandle } from "../workspace";
20
20
  import { ContextRail } from "./ContextRail";
21
+ import { startTrailPoll } from "../trail/trail";
22
+ import { TRAIL_TICK_MS, trailLive, trailMarks, trailTurns } from "../trail/trail.model";
23
+ import type { TrailReplay } from "../trail/trail.model";
21
24
  import { ResizeHandle } from "./ResizeHandle";
22
25
  import { deeplinkSelection, formatHash } from "./deeplink.model";
23
26
  import { Settings } from "./Settings";
@@ -51,7 +54,7 @@ function RecentList(props: { visits: readonly string[]; graph: GraphPayload | nu
51
54
  /** A tab view can unmount; the shared draft store outlives it. */
52
55
  function DocumentView(props: {
53
56
  preferences: Preferences; tab: WorkspaceTab; graph: GraphPayload | null; drafts: DraftStore; revision: number;
54
- onSelect: (id: string, newTab?: boolean) => void; onSave: (slug: string, body: string) => Promise<boolean>;
57
+ onSelect: (id: string, newTab?: boolean) => void; onSave: SaveNote;
55
58
  now: number; onScroll: (value: number) => void; onSearch: () => void; onGraph: () => void; onCreate: (title: string) => Promise<boolean>;
56
59
  }) {
57
60
  const id = tabSelection(props.tab);
@@ -110,7 +113,7 @@ function DocumentView(props: {
110
113
  }}><Note spellcheck={props.preferences.spellcheck} defaultEdit={props.preferences.defaultEdit} note={payload?.note ?? null} loadFailed={payload?.failed ?? false} loadVersion={payload?.version ?? 0} graph={props.graph} selectedId={id}
111
114
  onSelect={props.onSelect} onOpen={(noteSlug) => void openNote(fetchJson, noteSlug).then((result) => {
112
115
  if (!result.ok) window.alert(result.message); else if (!result.data.opened) window.alert("Could not open the note in an editor.");
113
- })} onSave={props.onSave} drafts={props.drafts} idPrefix={props.tab.id} now={props.now} /></div>;
116
+ })} onSave={props.onSave} onHistory={(noteSlug) => fetchHistory(fetchJson, noteSlug)} drafts={props.drafts} idPrefix={props.tab.id} now={props.now} /></div>;
114
117
  }
115
118
 
116
119
  export function Shell(props: ShellProps) {
@@ -119,6 +122,9 @@ export function Shell(props: ShellProps) {
119
122
  const [overlay, setOverlay] = useState<OverlayId>(null);
120
123
  const [contextGraph, setContextGraph] = useState(false);
121
124
  const [now, setNow] = useState(Date.now);
125
+ const [trail, setTrail] = useState<TrailPayload | null>(null);
126
+ const [trailNow, setTrailNow] = useState(Date.now);
127
+ const [replay, setReplay] = useState<TrailReplay | null>(null);
122
128
  const [width, setWidth] = useState(props.initialWidth);
123
129
  const [scheme, setScheme] = useState(() => schemeOf(window));
124
130
  const [ready, setReady] = useState(false);
@@ -199,8 +205,8 @@ export function Shell(props: ShellProps) {
199
205
  };
200
206
  live.current = { layout, overlay, selectedId, graphPreviewId, select };
201
207
  const mayMutate = (slugs: readonly string[]): boolean => mayMutateDrafts(drafts, slugs, () => window.confirm("Discard unsaved changes to the affected notes before changing files?"));
202
- const save = async (slug: string, body: string): Promise<boolean> => {
203
- const result = await saveNote(fetchJson, slug, body);
208
+ const save: SaveNote = async (slug, body, options) => {
209
+ const result = await saveNote(fetchJson, slug, body, options?.raw === true);
204
210
  if (!result.ok) { window.alert(result.message); return false; }
205
211
  setRevision((value) => value + 1); workspace.current?.refresh(); return true;
206
212
  };
@@ -272,6 +278,24 @@ export function Shell(props: ShellProps) {
272
278
  }, 350);
273
279
  return () => window.clearTimeout(timer);
274
280
  }, [layout, ready]);
281
+ // The recall trail: its own poll, and a fade clock that runs only while
282
+ // something is glowing (or a replay still has steps to light).
283
+ useEffect(() => startTrailPoll({
284
+ fetch: fetchJson,
285
+ onTrail: (next) => { setTrail(next); setReplay(null); setTrailNow(Date.now()); },
286
+ }), []);
287
+ const trailGlowing = trailLive(trail, trailNow, replay);
288
+ useEffect(() => {
289
+ if (!trailGlowing) {
290
+ if (replay !== null) setReplay(null);
291
+ return;
292
+ }
293
+ const tick = window.setInterval(() => setTrailNow(Date.now()), TRAIL_TICK_MS);
294
+ return () => window.clearInterval(tick);
295
+ }, [trailGlowing]);
296
+ const glows = useMemo(() => trailMarks(trail, trailNow, replay), [trail, trailNow, replay]);
297
+ const turns = useMemo(() => trailTurns(trail, data.graph), [trail, data.graph]);
298
+ const replayTurn = (turn: number): void => { const at = Date.now(); setTrailNow(at); setReplay({ turn, startedAt: at }); };
275
299
  useEffect(() => {
276
300
  const tick = window.setInterval(() => setNow(Date.now()), TICK_MS);
277
301
  const resize = (): void => setWidth(window.innerWidth);
@@ -452,7 +476,7 @@ export function Shell(props: ShellProps) {
452
476
  </nav>
453
477
  <div class="weave-sidebar-dock weave-notes-dock" data-open={layout.treeVisible} inert={!layout.treeVisible} aria-hidden={!layout.treeVisible} style={{ "--weave-sidebar-width": `${Math.min(layout.treeWidth, width - 90)}px` }}><aside class="weave-sidebar weave-sidebar-notes" aria-label="Notes sidebar">
454
478
  <div class="weave-sidebar-heading"><button type="button" aria-pressed={!recentMode} onClick={() => setRecentMode(false)}>Files</button><button type="button" aria-pressed={recentMode} onClick={() => setRecentMode(true)}>Recent</button><button type="button" aria-label="Hide notes sidebar" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle notes sidebar"]')?.focus(); setLayout({ ...layout, treeVisible: false }); }}>«</button></div>
455
- {recentMode ? <RecentList visits={visits} graph={data.graph} selectedId={selectedId} onSelect={select} /> : <Tree graph={data.graph} selectedId={selectedId} recentIds={data.recentIds} onSelect={(id, newTab, keepSidebar) => select(id, newTab, layout.activePane, keepSidebar)} onMutate={mayMutate} onRefresh={() => workspace.current?.refresh()} now={now} />}
479
+ {recentMode ? <RecentList visits={visits} graph={data.graph} selectedId={selectedId} onSelect={select} /> : <Tree graph={data.graph} selectedId={selectedId} recentIds={data.recentIds} trail={glows} onSelect={(id, newTab, keepSidebar) => select(id, newTab, layout.activePane, keepSidebar)} onMutate={mayMutate} onRefresh={() => workspace.current?.refresh()} now={now} />}
456
480
  <div class="weave-vault-label"><span>Workspace</span><strong title={props.cwd}>{props.cwd.split(/[\\/]/).filter(Boolean).pop() ?? "Weave"}</strong></div>
457
481
  </aside><ResizeHandle label="Resize notes sidebar" min={180} max={420} value={layout.treeWidth} onChange={(delta) => setLayout((value) => ({ ...value, treeWidth: Math.max(180, Math.min(420, value.treeWidth + delta)) }))} /></div>
458
482
  <main class={`weave-panes weave-split-${layout.split}`} style={layout.panes.length === 2 && width >= 850 ? { [layout.split === "right" ? "gridTemplateColumns" : "gridTemplateRows"]: `minmax(0, ${layout.ratio}fr) 4px minmax(0, ${1 - layout.ratio}fr)` } : {}}>
@@ -461,7 +485,7 @@ export function Shell(props: ShellProps) {
461
485
  setLayout((value) => ({ ...value, ratio: Math.max(.2, Math.min(.8, value.ratio + delta / (extent ?? 800))) }));
462
486
  }} /> : null}{renderPane(group)}</>)}
463
487
  </main>
464
- <div class="weave-sidebar-dock weave-context-dock" data-open={contextVisible} inert={!contextVisible} aria-hidden={!contextVisible} style={{ "--weave-sidebar-width": `${layout.contextWidth}px` }}><ResizeHandle label="Resize context sidebar" min={180} max={400} value={layout.contextWidth} onChange={(delta) => setLayout((value) => ({ ...value, contextWidth: Math.max(180, Math.min(400, value.contextWidth - delta)) }))} /><aside class="weave-sidebar weave-sidebar-context" aria-label="Context sidebar"><div class="weave-sidebar-heading"><strong>Context</strong><button type="button" aria-label="Hide context sidebar" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle context sidebar"]')?.focus(); setCompactContext(false); setLayout({ ...layout, contextVisible: false }); }}>»</button></div>{contextGraphActive ? <section class="weave-context-graph-section" aria-label="Note connections"><div class="weave-context-graph-heading"><strong>Note graph</strong><button type="button" aria-label="Hide note graph" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle note graph"]')?.focus(); setContextGraph(false); }}>×</button></div><div class="weave-context-graph-slot" ref={contextGraphSlot} style={{ height: layout.contextGraphHeight }}>{documentId === null ? <p>Select a note to see its connections.</p> : null}</div><ResizeHandle label="Resize note graph" horizontal min={120} max={800} value={graphBox.height} onChange={(delta) => setLayout((value) => ({ ...value, contextGraphHeight: Math.max(120, Math.min(800, (contextGraphSlot.current?.clientHeight ?? value.contextGraphHeight) + delta)) }))} /></section> : null}<ContextRail graph={data.graph} selectedId={selectedId} onSelect={select} tagHeight={layout.contextTagsHeight} onTagResize={(contextTagsHeight) => setLayout((value) => ({ ...value, contextTagsHeight }))} /></aside></div>
488
+ <div class="weave-sidebar-dock weave-context-dock" data-open={contextVisible} inert={!contextVisible} aria-hidden={!contextVisible} style={{ "--weave-sidebar-width": `${layout.contextWidth}px` }}><ResizeHandle label="Resize context sidebar" min={180} max={400} value={layout.contextWidth} onChange={(delta) => setLayout((value) => ({ ...value, contextWidth: Math.max(180, Math.min(400, value.contextWidth - delta)) }))} /><aside class="weave-sidebar weave-sidebar-context" aria-label="Context sidebar"><div class="weave-sidebar-heading"><strong>Context</strong><button type="button" aria-label="Hide context sidebar" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle context sidebar"]')?.focus(); setCompactContext(false); setLayout({ ...layout, contextVisible: false }); }}>»</button></div>{contextGraphActive ? <section class="weave-context-graph-section" aria-label="Note connections"><div class="weave-context-graph-heading"><strong>Note graph</strong><button type="button" aria-label="Hide note graph" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle note graph"]')?.focus(); setContextGraph(false); }}>×</button></div><div class="weave-context-graph-slot" ref={contextGraphSlot} style={{ height: layout.contextGraphHeight }}>{documentId === null ? <p>Select a note to see its connections.</p> : null}</div><ResizeHandle label="Resize note graph" horizontal min={120} max={800} value={graphBox.height} onChange={(delta) => setLayout((value) => ({ ...value, contextGraphHeight: Math.max(120, Math.min(800, (contextGraphSlot.current?.clientHeight ?? value.contextGraphHeight) + delta)) }))} /></section> : null}<ContextRail graph={data.graph} selectedId={selectedId} onSelect={select} trail={turns} trailTurn={replay?.turn ?? null} onReplay={replayTurn} tagHeight={layout.contextTagsHeight} onTagResize={(contextTagsHeight) => setLayout((value) => ({ ...value, contextTagsHeight }))} /></aside></div>
465
489
  {hasGraph || contextGraph ? <div class={`weave-graph-host${contextGraphActive ? " weave-context-graph-host" : ""}`}
466
490
  onDragOver={(event) => { if (!contextGraphActive && graphPane) tabDragOver(graphPane.id, event); }} onDrop={(event) => { if (!contextGraphActive && graphPane) tabDrop(graphPane.id, event); }}
467
491
  onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropPane(null); }}
@@ -469,7 +493,7 @@ export function Shell(props: ShellProps) {
469
493
  onFocusCapture={() => { if (!contextGraphActive && graphPane) setLayout((current) => ({ ...current, activePane: graphPane.id })); }}
470
494
  aria-hidden={!graphVisible} inert={!graphVisible} role={contextGraphActive ? "region" : undefined} aria-label={contextGraphActive ? "Note graph" : undefined}
471
495
  style={{ ...graphBox, visibility: graphVisible ? "visible" : "hidden", pointerEvents: graphVisible ? "auto" : "none" }}>
472
- <Graph graph={data.graph} selectedId={selectedId} previewId={graphPreviewId} onSelect={selectGraph} onOpen={openGraphNode} renderer={createSigmaRenderer} storage={localStorage} host={window} scheme={graphTheme} forces={layout.preferences.forces} groupColors={layout.preferences.groupColors} bootFailed={data.graphFailed} fit={fit} />
496
+ <Graph graph={data.graph} selectedId={selectedId} previewId={graphPreviewId} onSelect={selectGraph} onOpen={openGraphNode} renderer={createSigmaRenderer} storage={localStorage} host={window} scheme={graphTheme} forces={layout.preferences.forces} groupColors={layout.preferences.groupColors} bootFailed={data.graphFailed} fit={fit} trail={glows} />
473
497
  </div> : null}
474
498
  </div>
475
499
  <div class="weave-footer"><StatusBar model={statusBarModel(props.cwd, selectedId)} />
@@ -41,7 +41,7 @@
41
41
  * `⌘K`, which would otherwise re-open the palette on top of itself.
42
42
  */
43
43
 
44
- import { COLUMNS, type ColumnId, type OverlayId } from "./shell.model";
44
+ import type { ColumnId, OverlayId } from "./shell.model";
45
45
 
46
46
  // --- the event, without the DOM ---------------------------------------------------
47
47
 
@@ -62,6 +62,12 @@ export interface KeyDescriptor {
62
62
  readonly alt: boolean;
63
63
  /** Focus is in an `<input>`, `<textarea>`, `<select>` or contenteditable. */
64
64
  readonly typing: boolean;
65
+ /**
66
+ * Focus is in the tree or the graph — the columns that *make* a selection,
67
+ * and so the only places `Esc` may clear one. Read from a note, the same
68
+ * key used to close the note you were reading.
69
+ */
70
+ readonly navigating: boolean;
65
71
  }
66
72
 
67
73
  /** What the shell knows that changes what a key means. */
@@ -172,11 +178,12 @@ export function shellKey(event: KeyDescriptor, ctx: KeyContext): ShellAction | n
172
178
  return column === undefined ? null : { type: "focusColumn", column };
173
179
  }
174
180
 
175
- // Only claimed when there is a selection to clear. With nothing selected,
176
- // `Esc` belongs to whatever else the browser or a future dialog wants it
177
- // for, and swallowing it silently would be the kind of key theft this
178
- // module exists to avoid.
179
- if (event.key === "Escape") return ctx.hasSelection ? { type: "clearSelection" } : null;
181
+ // Only claimed when there is a selection to clear, and only from the tree
182
+ // or the graph. With nothing selected, `Esc` belongs to whatever else the
183
+ // browser or a future dialog wants it for, and swallowing it silently would
184
+ // be the kind of key theft this module exists to avoid. From the note
185
+ // column it would close the note being read, which nobody means by it.
186
+ if (event.key === "Escape") return ctx.hasSelection && event.navigating ? { type: "clearSelection" } : null;
180
187
 
181
188
  if (!isBare(event) || event.typing) return null;
182
189
  return BARE_KEYS[event.key] ?? null;
@@ -216,8 +223,13 @@ export function isTextEntry(tagName: string | null, contentEditable: boolean): b
216
223
  export interface KeyTarget {
217
224
  readonly tagName?: string | undefined;
218
225
  readonly isContentEditable?: boolean | undefined;
226
+ /** `Element.closest`; absent on `window` and `document`. */
227
+ closest?(selector: string): unknown;
219
228
  }
220
229
 
230
+ /** The tree and graph columns, where {@link KeyDescriptor.navigating} holds. */
231
+ export const NAVIGATING_SELECTOR = ".weave-sidebar-notes, .weave-graph-host";
232
+
221
233
  /**
222
234
  * The slice of `KeyboardEvent` the subscription reads.
223
235
  *
@@ -246,6 +258,7 @@ export function describeKey(event: KeyboardEventLike): KeyDescriptor {
246
258
  shift: event.shiftKey,
247
259
  alt: event.altKey,
248
260
  typing: isTextEntry(event.target?.tagName ?? null, event.target?.isContentEditable === true),
261
+ navigating: (event.target?.closest?.(NAVIGATING_SELECTOR) ?? null) !== null,
249
262
  };
250
263
  }
251
264
 
@@ -395,7 +408,7 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
395
408
  })),
396
409
  { combo: "?", what: "This help" },
397
410
  { combo: "t", what: "Switch between selected light and dark themes" },
398
- { combo: "Esc", what: "Clear the selection, or close an overlay" },
411
+ { combo: "Esc", what: "Clear the selection from the tree or graph, or close an overlay" },
399
412
  ],
400
413
  },
401
414
  {
@@ -178,6 +178,16 @@ body{font-size:var(--weave-px-base)}
178
178
  40%{font-weight:700}
179
179
  100%{background:transparent;font-weight:400}
180
180
  }
181
+ /* Recall trail (issue #78): a note the agent searched, read or wrote. The
182
+ tint follows the graph's palette for the same kinds (warn → ok → accent)
183
+ and steps down through three strength buckets as the glow fades. */
184
+ .weave-row-trail{--weave-trail:var(--weave-warn);box-shadow:inset 2px 0 0 color-mix(in srgb,var(--weave-trail) var(--weave-trail-mix),transparent);background:color-mix(in srgb,var(--weave-trail) calc(var(--weave-trail-mix) / 6),transparent)}
185
+ .weave-row-trail-read{--weave-trail:var(--weave-ok)}
186
+ .weave-row-trail-wrote{--weave-trail:var(--weave-accent)}
187
+ .weave-row-trail-3{--weave-trail-mix:90%}
188
+ .weave-row-trail-2{--weave-trail-mix:55%}
189
+ .weave-row-trail-1{--weave-trail-mix:25%}
190
+ .weave-row-trail-wrote.weave-row-trail-3 .weave-label{font-weight:600}
181
191
  .weave-twisty{width:16px;height:16px;flex:none;display:inline-flex;align-items:center;justify-content:center;color:var(--weave-faint);cursor:pointer}
182
192
  .weave-kind{flex:none;color:var(--weave-faint);font-size:var(--weave-px-ui)}
183
193
  .weave-prov{flex:none;font-size:var(--weave-px-prov)}
@@ -263,14 +273,103 @@ body{font-size:var(--weave-px-base)}
263
273
  /* Save is the row's only affirmative action, so the only one with the accent. */
264
274
  .weave-note-save{color:var(--weave-accent);border-color:var(--weave-accent)}
265
275
  .weave-note-save:disabled{color:var(--weave-faint);border-color:var(--weave-line-strong);cursor:default}
276
+ /* History: a slim bar under the header. The step's added text is marked in
277
+ the accent tint, the same strength the selected rows use. */
278
+ .weave-note-history[aria-pressed="true"]{color:var(--weave-accent);border-color:var(--weave-accent)}
279
+ /* The replay band sits under the header like the header itself: full width,
280
+ the note's gutter, one line of words, one line of controls. Dots on the
281
+ track are the versions, coloured by who made them. */
282
+ .weave-replay{
283
+ display:flex;flex-direction:column;gap:8px;padding:10px var(--weave-note-gutter) 12px;
284
+ background:var(--weave-panel);border-bottom:1px solid var(--weave-line);
285
+ font-size:var(--weave-px-ui);color:var(--weave-dim);
286
+ }
287
+ .weave-replay-note{color:var(--weave-faint)}
288
+ .weave-replay-caption{margin:0;display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-height:18px}
289
+ .weave-replay-clock{display:inline-flex;color:var(--weave-dim)}
290
+ .weave-replay-position{font-weight:650;color:var(--weave-fg);font-variant-numeric:tabular-nums}
291
+ .weave-replay-who{display:inline-flex;align-items:center;gap:6px;color:var(--weave-dim)}
292
+ .weave-replay-who::before{content:"";width:8px;height:8px;border-radius:999px;background:var(--weave-who)}
293
+ .weave-replay-time{color:var(--weave-faint)}
294
+ .weave-replay-change{color:var(--weave-ok);font-size:var(--weave-px-caption);font-variant-numeric:tabular-nums}
295
+ .weave-replay-human{--weave-who:var(--weave-ok)}
296
+ .weave-replay-agent{--weave-who:var(--weave-accent)}
297
+ .weave-replay-outside,.weave-replay-system{--weave-who:var(--weave-faint)}
298
+ .weave-replay-controls{display:flex;align-items:center;gap:6px}
299
+ .weave-replay-controls button{
300
+ flex:none;display:inline-grid;place-items:center;width:28px;height:28px;padding:0;cursor:pointer;
301
+ color:var(--weave-fg);background:var(--weave-page);
302
+ border:1px solid var(--weave-line-strong);border-radius:var(--weave-radius-pop);
303
+ }
304
+ .weave-replay-controls button:hover:not(:disabled){border-color:var(--weave-accent)}
305
+ .weave-replay-controls button:focus-visible{outline:2px solid var(--weave-accent);outline-offset:1px}
306
+ .weave-replay-controls button:disabled{color:var(--weave-faint);cursor:default;opacity:.6}
307
+ .weave-replay-controls .weave-replay-play{color:var(--weave-page);background:var(--weave-accent);border-color:var(--weave-accent)}
308
+ .weave-replay-controls .weave-replay-play:hover:not(:disabled){filter:brightness(1.08)}
309
+ .weave-replay-track{--weave-replay-at:100%;position:relative;flex:1;min-width:80px;height:28px;margin-left:8px}
310
+ .weave-replay-track::before{
311
+ content:"";position:absolute;left:7px;right:7px;top:13px;height:2px;border-radius:999px;
312
+ background:linear-gradient(to right,color-mix(in srgb,var(--weave-accent) 55%,transparent) var(--weave-replay-at),var(--weave-line-strong) var(--weave-replay-at));
313
+ }
314
+ .weave-replay-ticks{position:absolute;left:7px;right:7px;top:0;bottom:0;display:flex;justify-content:space-between;align-items:center;pointer-events:none}
315
+ .weave-replay-tick{position:relative;width:0;height:0}
316
+ .weave-replay-tick::before{
317
+ content:"";position:absolute;left:-6px;top:-6px;width:12px;height:12px;box-sizing:border-box;border-radius:999px;
318
+ background:var(--weave-who);border:2px solid var(--weave-panel);
319
+ }
320
+ .weave-replay-ahead::before{opacity:.4}
321
+ .weave-replay-slider{position:absolute;inset:0;width:100%;height:100%;margin:0;background:transparent;-webkit-appearance:none;appearance:none;cursor:pointer}
322
+ .weave-replay-slider:disabled{cursor:default}
323
+ .weave-replay-slider:focus{outline:none}
324
+ .weave-replay-slider::-webkit-slider-runnable-track{height:28px;background:transparent}
325
+ .weave-replay-slider::-moz-range-track{height:28px;background:transparent}
326
+ .weave-replay-slider::-webkit-slider-thumb{
327
+ -webkit-appearance:none;appearance:none;width:14px;height:14px;margin-top:7px;border-radius:999px;
328
+ background:var(--weave-page);border:2px solid var(--weave-accent);box-sizing:border-box;
329
+ }
330
+ .weave-replay-slider::-moz-range-thumb{
331
+ width:14px;height:14px;border-radius:999px;box-sizing:border-box;
332
+ background:var(--weave-page);border:2px solid var(--weave-accent);
333
+ }
334
+ /* Focus fills the thumb: a ring would need an elevation shadow. */
335
+ .weave-replay-slider:focus-visible::-webkit-slider-thumb{background:var(--weave-accent);border-color:var(--weave-page)}
336
+ .weave-replay-slider:focus-visible::-moz-range-thumb{background:var(--weave-accent);border-color:var(--weave-page)}
337
+ .weave-replay-hint{margin:0;color:var(--weave-faint)}
338
+ .weave-replay-mark{background:var(--weave-new);color:inherit}
266
339
  /* Mono and a tighter line than the prose: this is Markdown source, where
267
340
  alignment carries meaning proportional text throws away. */
268
341
  .weave-note-editor{
269
- display:block;width:100%;flex:1;min-height:0;resize:none;
342
+ display:block;width:100%;flex:1 0 auto;min-height:12em;resize:none;
270
343
  padding:12px var(--weave-note-gutter) 28px;box-sizing:border-box;
271
344
  font-family:var(--weave-mono);font-size:var(--weave-px-row);line-height:1.6;
272
345
  color:var(--weave-fg);background:var(--weave-bg);border:0;outline:0;
273
346
  }
347
+ /* The \`## Raw\` box under the editor: folded by default so a long
348
+ dictation never outgrows the note, a dashed edge to say "captured, not
349
+ written", and a capped height once open. */
350
+ .weave-raw{
351
+ flex:none;margin:0 var(--weave-note-gutter) 24px;
352
+ border:1px dashed var(--weave-line-strong);border-radius:var(--weave-radius-pop);background:var(--weave-panel);
353
+ }
354
+ .weave-raw-head{display:flex;align-items:center;gap:10px;padding:5px 8px;min-height:24px}
355
+ .weave-raw[data-open] .weave-raw-head{border-bottom:1px dashed var(--weave-line-strong)}
356
+ .weave-raw-toggle{
357
+ display:inline-flex;align-items:center;gap:6px;padding:3px 6px;cursor:pointer;
358
+ font:inherit;font-size:var(--weave-px-ui);font-weight:600;color:var(--weave-fg);
359
+ background:none;border:0;border-radius:var(--weave-radius);
360
+ }
361
+ .weave-raw-toggle:hover{background:var(--weave-line)}
362
+ .weave-raw-toggle:focus-visible{outline:2px solid var(--weave-accent);outline-offset:1px}
363
+ .weave-raw-chevron{flex:none;color:var(--weave-dim);transition:transform .12s ease}
364
+ .weave-raw-toggle[aria-expanded="true"] .weave-raw-chevron{transform:rotate(90deg)}
365
+ .weave-raw-hint{font-size:var(--weave-px-caption);color:var(--weave-faint)}
366
+ .weave-raw-editor{
367
+ display:block;width:100%;box-sizing:border-box;min-height:7em;max-height:18em;
368
+ field-sizing:content;resize:vertical;padding:10px 14px 12px;
369
+ font-family:var(--weave-mono);font-size:var(--weave-px-row);line-height:1.6;
370
+ color:var(--weave-dim);background:transparent;border:0;outline:0;
371
+ }
372
+ .weave-raw-editor:focus{color:var(--weave-fg)}
274
373
  .weave-note-tags{margin:6px 0 0;display:flex;gap:5px;flex-wrap:wrap}
275
374
  .weave-tag{
276
375
  font-size:var(--weave-px-caption);color:var(--weave-accent);background:var(--weave-panel);
@@ -433,6 +532,24 @@ body{font-size:var(--weave-px-base)}
433
532
  border, cursor — plus a full-width flex row so the count badge lands on the
434
533
  right edge of the *column*, not of the text. */
435
534
  .weave-ctx-group{padding:2px var(--weave-gutter) 2px}
535
+ .weave-trail{padding-top:6px;padding-bottom:6px;border-bottom:1px solid var(--weave-line)}
536
+ .weave-trail-head{display:flex;align-items:center;gap:6px;font-size:var(--weave-px-caption);font-weight:600;letter-spacing:.09em;color:var(--weave-faint);text-transform:uppercase}
537
+ .weave-trail-head .weave-ctx-count{margin-left:0}
538
+ .weave-trail-replay,.weave-trail-turn{font:inherit;font-size:var(--weave-px-caption);letter-spacing:0;text-transform:none;color:var(--weave-dim);background:none;border:1px solid var(--weave-line);border-radius:var(--weave-radius);padding:0 6px;cursor:pointer}
539
+ .weave-trail-replay{margin-left:auto}
540
+ .weave-trail-replay:hover,.weave-trail-turn:hover{color:var(--weave-fg)}
541
+ .weave-trail-turn-on{color:var(--weave-accent);border-color:var(--weave-accent)}
542
+ .weave-trail-steps{margin:4px 0 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:2px}
543
+ .weave-trail-step{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px;padding-left:10px;position:relative;line-height:1.45}
544
+ .weave-trail-step::before{content:"";position:absolute;left:0;top:.55em;width:6px;height:6px;border-radius:999px;background:var(--weave-warn)}
545
+ .weave-trail-read::before{background:var(--weave-ok)}
546
+ .weave-trail-wrote::before{background:var(--weave-accent)}
547
+ .weave-trail-verb{color:var(--weave-dim)}
548
+ .weave-trail-detail{color:var(--weave-faint);font-family:var(--weave-mono);font-size:var(--weave-px-caption)}
549
+ .weave-trail-note{font:inherit;color:var(--weave-fg);background:none;border:0;padding:0;cursor:pointer;text-decoration:underline;text-decoration-color:var(--weave-line-strong);text-underline-offset:2px}
550
+ .weave-trail-note:hover{color:var(--weave-accent)}
551
+ .weave-trail-empty{margin:4px 0 0;color:var(--weave-faint)}
552
+ .weave-trail-turns{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
436
553
  .weave-ctx-head{margin:0}
437
554
  .weave-ctx-heading{
438
555
  display:flex;align-items:center;gap:5px;width:100%;margin:0;padding:2px 0;
@@ -74,6 +74,8 @@ export const WORKSPACE_CSS = `
74
74
  .weave-document .weave-note-body h2{font-size:var(--weave-px-title);margin-top:28px}
75
75
  .weave-document .weave-note-editor{padding:14px max(24px,calc((100% - var(--weave-reading-width,760px))/2));min-height:220px;flex:1;font-size:var(--weave-note-size,var(--weave-px-subhead))}
76
76
  .weave-document .weave-note-tags{margin-top:12px}
77
+ .weave-document .weave-replay{padding:12px max(24px,calc((100% - var(--weave-reading-width,760px))/2)) 14px}
78
+ .weave-document .weave-raw{margin:0 max(24px,calc((100% - var(--weave-reading-width,760px))/2)) 28px}
77
79
  .weave-document .weave-note-edit,.weave-document .weave-note-save{padding:5px 10px;border-radius:var(--weave-radius);font-size:var(--weave-px-caption)}
78
80
  .weave-document .weave-note-edit{color:var(--weave-dim);background:transparent;border-color:transparent}
79
81
  .weave-document .weave-note-edit:hover{color:var(--weave-accent);background:var(--weave-new);border-color:transparent}
@@ -128,6 +130,8 @@ export const WORKSPACE_CSS = `
128
130
  .weave-panes{grid-template-columns:minmax(0,1fr)!important;grid-template-rows:minmax(0,1fr)!important}
129
131
  .weave-document .weave-note-head{padding:20px}
130
132
  .weave-document .weave-note-body{padding:10px 20px 40px}
133
+ .weave-document .weave-replay{padding:12px 20px}
134
+ .weave-document .weave-raw{margin:0 20px 24px}
131
135
  }
132
136
  @media(prefers-reduced-motion:reduce){
133
137
  .weave-sidebar-dock,.weave-sidebar-dock[data-open=false],.weave-sidebar-dock>.weave-sidebar{transition:none}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The "This turn" strip at the top of the context rail (issue #78).
3
+ *
4
+ * Every string and every grouping comes from `trail.model.ts`; this renders
5
+ * the newest turn's steps as `searched "auth" → read Auth migration → …`,
6
+ * with each note a link, and the earlier turns as small time chips that
7
+ * replay their glow when clicked.
8
+ */
9
+
10
+ import type { TrailTurnView } from "./trail.model";
11
+ import { TRAIL_EMPTY_TURN, turnHeading } from "./trail.model";
12
+
13
+ export interface TrailStripProps {
14
+ turns: readonly TrailTurnView[];
15
+ /** The turn whose steps are listed — the replayed one, or `null` for the newest. */
16
+ shownTurn: number | null;
17
+ onSelect: (id: string, newTab?: boolean) => void;
18
+ onReplay: (turn: number) => void;
19
+ }
20
+
21
+ export function TrailStrip(props: TrailStripProps) {
22
+ const latest = props.turns[0];
23
+ if (latest === undefined) return null;
24
+ const shown = props.turns.find((view) => view.turn === props.shownTurn) ?? latest;
25
+ return (
26
+ <section class="weave-ctx-group weave-trail" aria-label="What your agent recalled">
27
+ <h4 class="weave-ctx-head weave-trail-head">
28
+ <span class="weave-trail-title">{turnHeading(shown, shown === latest)}</span>
29
+ <span class="weave-ctx-count" title="Notes read or written">{shown.used}</span>
30
+ {shown.steps.length === 0 ? null : <button type="button" class="weave-trail-replay" title="Replay this turn's glow" onClick={() => props.onReplay(shown.turn)}>Replay</button>}
31
+ </h4>
32
+ {shown.steps.length === 0 ? <p class="weave-trail-empty">{TRAIL_EMPTY_TURN}</p> : null}
33
+ <ol class="weave-trail-steps">
34
+ {shown.steps.map((step) => (
35
+ <li key={step.key} class={`weave-trail-step weave-trail-${step.kind}`}>
36
+ <span class="weave-trail-verb">{step.verb}</span>
37
+ {step.notes.map((note) => (
38
+ <button key={note.id} type="button" class="weave-trail-note" onClick={(event) => props.onSelect(note.id, event.metaKey || event.ctrlKey)}>{note.label}</button>
39
+ ))}
40
+ {step.detail === null ? null : <span class="weave-trail-detail">{step.detail}</span>}
41
+ </li>
42
+ ))}
43
+ </ol>
44
+ {props.turns.length < 2 ? null : (
45
+ <div class="weave-trail-turns" role="group" aria-label="Earlier turns">
46
+ {props.turns.map((view, index) => (
47
+ <button key={view.turn} type="button" class={`weave-trail-turn${view.turn === shown.turn ? " weave-trail-turn-on" : ""}`} aria-pressed={view.turn === shown.turn} onClick={() => props.onReplay(view.turn)}>
48
+ {turnHeading(view, index === 0)}
49
+ </button>
50
+ ))}
51
+ </div>
52
+ )}
53
+ </section>
54
+ );
55
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The recall trail, decided (issue #78).
3
+ *
4
+ * `GET /api/trail` lists the notes the agent searched, read and wrote. This
5
+ * module turns that list into the two things the workspace draws:
6
+ *
7
+ * - {@link trailMarks}: which nodes glow right now, how, and how strongly.
8
+ * The glow fades over {@link TRAIL_FADE_MS}; a replayed turn relights its
9
+ * steps one after another from the moment it was clicked.
10
+ * - {@link trailTurns}: the "This turn" strip — each turn's steps in order,
11
+ * worded as `searched "auth" → read Auth migration → appended Auth migration`.
12
+ *
13
+ * Pure, DOM-free and clock-free (every function takes `now`), like the other
14
+ * `*.model.ts` files, so the whole feature is covered by unit tests.
15
+ */
16
+
17
+ import type { GraphPayload, TrailPayload, WireTrailEvent, WireTrailKind } from "../../shared/wire";
18
+
19
+ /** How long a glow takes to fade out completely. */
20
+ export const TRAIL_FADE_MS = 60_000;
21
+
22
+ /** How often the fade repaints while something is glowing. */
23
+ export const TRAIL_TICK_MS = 1_000;
24
+
25
+ /** In a replay, the gap between one step lighting and the next. */
26
+ export const REPLAY_STEP_MS = 700;
27
+
28
+ /** How many turns the strip lists. */
29
+ export const TRAIL_TURNS_SHOWN = 5;
30
+
31
+ /** One glowing node. `strength` runs 1 (just happened) → 0 (faded). */
32
+ export interface TrailMark {
33
+ kind: WireTrailKind;
34
+ strength: number;
35
+ }
36
+
37
+ /** A replay in progress: which turn, and when it was clicked. */
38
+ export interface TrailReplay {
39
+ turn: number;
40
+ startedAt: number;
41
+ }
42
+
43
+ /** A write outranks a read, which outranks a search hit. */
44
+ const RANK: Readonly<Record<WireTrailKind, number>> = { searched: 0, read: 1, wrote: 2 };
45
+
46
+ const NO_MARKS: ReadonlyMap<string, TrailMark> = new Map();
47
+
48
+ /** When each event lights, or `null` if it is not part of what is shown. */
49
+ function lightTimes(trail: TrailPayload, replay: TrailReplay | null): Array<{ event: WireTrailEvent; at: number }> {
50
+ if (replay === null) return trail.events.map((event) => ({ event, at: Date.parse(event.at) }));
51
+ return trail.events
52
+ .filter((event) => event.turn === replay.turn)
53
+ .map((event, index) => ({ event, at: replay.startedAt + index * REPLAY_STEP_MS }));
54
+ }
55
+
56
+ /**
57
+ * Node id → glow. A node touched several ways in one turn shows the strongest
58
+ * kind (wrote > read > searched) at the freshest strength; a later turn's
59
+ * touch replaces an earlier turn's, so a note read now is not still shown as
60
+ * written a turn ago.
61
+ *
62
+ * Live, an event stamped slightly in the future (clock skew) glows at full
63
+ * strength; in a replay a step whose moment has not come yet stays dark.
64
+ */
65
+ export function trailMarks(trail: TrailPayload | null, now: number, replay: TrailReplay | null = null): ReadonlyMap<string, TrailMark> {
66
+ if (trail === null) return NO_MARKS;
67
+ const marks = new Map<string, TrailMark>();
68
+ const turnOf = new Map<string, number>();
69
+ for (const { event, at } of lightTimes(trail, replay)) {
70
+ const age = now - at;
71
+ if (replay !== null && age < 0) continue;
72
+ const strength = Math.min(1, 1 - age / TRAIL_FADE_MS);
73
+ if (!(strength > 0)) continue;
74
+ for (const slug of event.slugs) {
75
+ const id = `note:${slug}`;
76
+ // A newer turn's touch replaces an older turn's; within a turn the strongest kind wins.
77
+ const existing = turnOf.get(id) === event.turn ? marks.get(id) : undefined;
78
+ const kind = existing === undefined || RANK[event.kind] > RANK[existing.kind] ? event.kind : existing.kind;
79
+ marks.set(id, { kind, strength: Math.max(strength, existing?.strength ?? 0) });
80
+ turnOf.set(id, event.turn);
81
+ }
82
+ }
83
+ return marks.size === 0 ? NO_MARKS : marks;
84
+ }
85
+
86
+ /** Whether anything is still glowing or still to light — i.e. whether the fade clock should run. */
87
+ export function trailLive(trail: TrailPayload | null, now: number, replay: TrailReplay | null = null): boolean {
88
+ if (trail === null) return false;
89
+ return lightTimes(trail, replay).some(({ at }) => now - at < TRAIL_FADE_MS);
90
+ }
91
+
92
+ /** One note named in a step, ready to be a link. */
93
+ export interface TrailNoteView {
94
+ id: string;
95
+ label: string;
96
+ }
97
+
98
+ /** One step of a turn. */
99
+ export interface TrailStepView {
100
+ key: string;
101
+ kind: WireTrailKind;
102
+ /** `searched "auth"`, `read`, `appended`, … */
103
+ verb: string;
104
+ /** The notes to link. Empty for a search, which names its hit count in `detail` instead. */
105
+ notes: TrailNoteView[];
106
+ detail: string | null;
107
+ }
108
+
109
+ /** One turn in the strip, newest first. */
110
+ export interface TrailTurnView {
111
+ turn: number;
112
+ /** Local `HH:MM` of the turn's first step. */
113
+ time: string;
114
+ steps: TrailStepView[];
115
+ /** Distinct notes read or written. */
116
+ used: number;
117
+ }
118
+
119
+ const WROTE_VERB: Readonly<Record<string, string>> = {
120
+ add: "created",
121
+ append: "appended",
122
+ finalize: "finalized",
123
+ links: "fixed links in",
124
+ };
125
+
126
+ function stepView(event: WireTrailEvent, labels: ReadonlyMap<string, string>): TrailStepView {
127
+ const notes = event.slugs.map((slug) => ({ id: `note:${slug}`, label: labels.get(`note:${slug}`) ?? slug }));
128
+ if (event.kind === "searched") {
129
+ const hits = event.slugs.length;
130
+ return {
131
+ key: `${event.seq}:s`,
132
+ kind: "searched",
133
+ verb: event.query === undefined ? "searched" : `searched "${event.query}"`,
134
+ notes: [],
135
+ detail: hits === 0 ? "no hits" : `${hits} hit${hits === 1 ? "" : "s"}`,
136
+ };
137
+ }
138
+ return {
139
+ key: `${event.seq}:${event.kind}`,
140
+ kind: event.kind,
141
+ verb: event.kind === "read" ? "read" : WROTE_VERB[event.action] ?? "wrote",
142
+ notes,
143
+ detail: null,
144
+ };
145
+ }
146
+
147
+ function clock(iso: string, seconds = false): string {
148
+ const at = new Date(iso);
149
+ if (Number.isNaN(at.getTime())) return "";
150
+ const parts = [at.getHours(), at.getMinutes(), ...(seconds ? [at.getSeconds()] : [])];
151
+ return parts.map((part) => String(part).padStart(2, "0")).join(":");
152
+ }
153
+
154
+ /**
155
+ * The strip: the newest {@link TRAIL_TURNS_SHOWN} turns, each with its steps in order.
156
+ *
157
+ * When the harness has started a turn that has not touched a note yet (Pi's
158
+ * `agent_start` moves `trail.turn` ahead of the newest event), that empty
159
+ * turn leads the list, so the previous turn is never presented as current.
160
+ */
161
+ export function trailTurns(trail: TrailPayload | null, graph: GraphPayload | null): TrailTurnView[] {
162
+ if (trail === null || trail.events.length === 0) return [];
163
+ const labels = new Map<string, string>((graph?.model.nodes ?? []).map((node) => [node.id, node.label]));
164
+ const byTurn = new Map<number, WireTrailEvent[]>();
165
+ for (const event of trail.events) {
166
+ const events = byTurn.get(event.turn);
167
+ if (events === undefined) byTurn.set(event.turn, [event]);
168
+ else events.push(event);
169
+ }
170
+ const turns = [...byTurn.entries()].sort(([a], [b]) => b - a);
171
+ // Turns in the same minute would get identical chips, so those show seconds.
172
+ const minutes = turns.map(([, events]) => clock(events[0]!.at));
173
+ const views = turns.map(([turn, events], index) => {
174
+ const used = new Set(events.filter((event) => event.kind !== "searched").flatMap((event) => event.slugs));
175
+ const shared = minutes.filter((minute) => minute === minutes[index]).length > 1;
176
+ return { turn, time: clock(events[0]!.at, shared), steps: events.map((event) => stepView(event, labels)), used: used.size };
177
+ });
178
+ if (trail.turn > views[0]!.turn) views.unshift({ turn: trail.turn, time: "", steps: [], used: 0 });
179
+ return views.slice(0, TRAIL_TURNS_SHOWN);
180
+ }
181
+
182
+ /** The strip's heading for a turn: the latest is "This turn", older ones are their time. */
183
+ /** What the strip says for a turn with no steps yet. */
184
+ export const TRAIL_EMPTY_TURN = "Nothing recalled yet.";
185
+
186
+ export function turnHeading(view: TrailTurnView, latest: boolean): string {
187
+ return latest ? "This turn" : view.time === "" ? `Turn ${view.turn}` : view.time;
188
+ }
189
+
190
+ /** The tree row's class for a glow, or `""`. Strength is bucketed so the class list stays small. */
191
+ export function trailRowClass(mark: TrailMark | undefined): string {
192
+ if (mark === undefined) return "";
193
+ const level = mark.strength > 0.66 ? 3 : mark.strength > 0.33 ? 2 : 1;
194
+ return ` weave-row-trail weave-row-trail-${mark.kind} weave-row-trail-${level}`;
195
+ }