pi-weave 0.3.7 → 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.
- package/README.md +17 -16
- package/package.json +4 -3
- package/skills/weave-explore/SKILL.md +3 -5
- package/skills/weave-notepad/SKILL.md +30 -4
- package/skills/weave-notepad/references/second-thoughts.md +57 -0
- package/src/core/graph/build.ts +1 -1
- package/src/core/graph/current.ts +10 -8
- package/src/core/history.ts +417 -0
- package/src/core/index.ts +19 -0
- package/src/core/links/repair.ts +5 -68
- package/src/core/noteAction.ts +72 -2
- package/src/core/openInEditor.ts +3 -8
- package/src/core/repoAction.ts +20 -61
- package/src/core/repoIndex.ts +38 -0
- package/src/core/trail.ts +124 -0
- package/src/core/types.ts +1 -3
- package/src/core/vault.ts +116 -29
- package/src/core/view/focus.ts +2 -2
- package/src/core/view/index.ts +2 -3
- package/src/core/view/rawTail.ts +153 -0
- package/src/core/workspace.ts +20 -26
- package/src/core/workspaceCommands.ts +14 -14
- package/src/opencode/tools.ts +3 -3
- package/src/opencode/tui.ts +1 -7
- package/src/pi/index.ts +68 -80
- package/src/pi/tools/noteTool.ts +1 -1
- package/src/pi/tools/repoTool.ts +3 -6
- package/src/pi/viewer/web/run.ts +6 -8
- package/src/web/client/api.ts +103 -5
- package/src/web/client/dist/app.js +183 -52
- package/src/web/client/graph/Graph.tsx +23 -2
- package/src/web/client/graph/column.model.ts +1 -1
- package/src/web/client/graph/graph.model.ts +69 -7
- package/src/web/client/graph/renderer.ts +19 -3
- package/src/web/client/note/Note.tsx +171 -20
- package/src/web/client/note/Replay.tsx +128 -0
- package/src/web/client/note/drafts.ts +50 -9
- package/src/web/client/note/note.model.ts +17 -0
- package/src/web/client/note/replay.model.ts +173 -0
- package/src/web/client/shell/ContextRail.tsx +7 -0
- package/src/web/client/shell/Settings.tsx +21 -1
- package/src/web/client/shell/Shell.tsx +34 -10
- package/src/web/client/shell/keys.model.ts +20 -7
- package/src/web/client/shell/theme.ts +118 -1
- package/src/web/client/shell/workspace.css.ts +4 -0
- package/src/web/client/trail/TrailStrip.tsx +55 -0
- package/src/web/client/trail/trail.model.ts +195 -0
- package/src/web/client/trail/trail.ts +45 -0
- package/src/web/client/tree/Tree.tsx +7 -3
- package/src/web/client/tree/tree.model.ts +2 -5
- package/src/web/server/page.ts +1 -1
- package/src/web/server/routes.ts +83 -3
- package/src/web/shared/logo.ts +1 -1
- package/src/web/shared/view.ts +10 -4
- package/src/web/shared/wire.ts +74 -1
- package/src/core/view/health.ts +0 -160
- package/src/pi/viewer/tui/bodyStore.ts +0 -95
- package/src/pi/viewer/tui/branding.ts +0 -14
- package/src/pi/viewer/tui/model.ts +0 -390
- package/src/pi/viewer/tui/openNote.ts +0 -11
- package/src/pi/viewer/tui/run.ts +0 -71
- package/src/pi/viewer/tui/surface/base.ts +0 -203
- package/src/pi/viewer/tui/surface/detail.ts +0 -315
- package/src/pi/viewer/tui/surface/explore.ts +0 -241
- package/src/pi/viewer/tui/surface/focus.ts +0 -139
- package/src/pi/viewer/tui/surface/health.ts +0 -102
- package/src/pi/viewer/tui/theme.ts +0 -90
- package/src/pi/viewer/tui/workspace.ts +0 -53
- 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:
|
|
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
|
|
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 {
|
|
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
|
|
176
|
-
// `Esc` belongs to whatever else the
|
|
177
|
-
// for, and swallowing it silently would
|
|
178
|
-
// module exists to avoid.
|
|
179
|
-
|
|
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:
|
|
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
|
+
}
|