pi-weave 0.3.10 → 0.3.12

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.
@@ -51,6 +51,8 @@ import {
51
51
  SHEET_LABEL,
52
52
  SHEET_OPEN,
53
53
  SHEET_OPEN_SPLIT,
54
+ SPOTLIGHT_BY_AGENT,
55
+ SPOTLIGHT_BY_AGENT_HINT,
54
56
  SPOTLIGHT_CLEAR,
55
57
  SPOTLIGHT_CLEAR_HINT,
56
58
  SPOTLIGHT_LABEL,
@@ -126,6 +128,8 @@ export interface GraphProps {
126
128
  * They light alongside the spotlight's own matches; omitted means none.
127
129
  */
128
130
  found?: readonly string[];
131
+ /** Whether `query` is the agent's newest search rather than the person's (`trail.model.ts`'s `agentSpotlight`). */
132
+ byAgent?: boolean;
129
133
  /**
130
134
  * Where the spotlight field writes. **Its presence is what makes this the
131
135
  * full graph view**: with it the column shows the floating spotlight field
@@ -150,6 +154,18 @@ export function Graph(props: GraphProps) {
150
154
  // mount frames the *starting* positions, which a settling simulation then
151
155
  // pushes past the edges — a two-note vault opened with clipped circles.
152
156
  const fitted = useRef(false);
157
+ // A re-frame asked for while the layout was still moving, done when it rests.
158
+ const reframe = useRef(false);
159
+ /**
160
+ * Frame the graph for where it is shown: the full view fits everything, and
161
+ * the Context card's note graph opens on the open note and its neighbours,
162
+ * since you read it while on that note (`renderer.ts`'s `focus`).
163
+ */
164
+ const frame = useCallback(() => {
165
+ const { full: whole, model: shown } = live.current;
166
+ if (whole) renderer.current?.fit();
167
+ else renderer.current?.focus(shown.highlight);
168
+ }, []);
153
169
  /**
154
170
  * Arm the simulation clock — idempotent.
155
171
  *
@@ -167,9 +183,10 @@ export function Graph(props: GraphProps) {
167
183
  engine.tick();
168
184
  // Asleep: let the clock die. The next pin re-arms it.
169
185
  if (!engine.awake()) {
170
- if (!fitted.current) {
186
+ if (!fitted.current || reframe.current) {
171
187
  fitted.current = true;
172
- renderer.current?.fit();
188
+ reframe.current = false;
189
+ frame();
173
190
  }
174
191
  return;
175
192
  }
@@ -216,8 +233,8 @@ export function Graph(props: GraphProps) {
216
233
  // Read by the mount-time `onSelect`, which outlives this render.
217
234
  const trailRef = useRef<ReadonlyMap<string, TrailGlow>>(NO_GLOWS);
218
235
  trailRef.current = props.trail ?? NO_GLOWS;
219
- const live = useRef({ view, model, picture, onSelect: props.onSelect, selectedId: props.selectedId });
220
- live.current = { view, model, picture, onSelect: props.onSelect, selectedId: props.selectedId };
236
+ const live = useRef({ view, model, picture, full, onSelect: props.onSelect, selectedId: props.selectedId });
237
+ live.current = { view, model, picture, full, onSelect: props.onSelect, selectedId: props.selectedId };
221
238
 
222
239
  useEffect(() => {
223
240
  const instance = props.renderer(scheme);
@@ -316,6 +333,16 @@ export function Graph(props: GraphProps) {
316
333
  renderer.current?.setHighlight(picture.highlight, picture.selectedId, picture.style);
317
334
  }, [picture]);
318
335
 
336
+ // Re-frame when another note opens under the Context card's graph, or the
337
+ // graph moves between the card and the full view. A click in the full view
338
+ // leaves the camera where it is. Before the first layout rests, the settle
339
+ // frames it; while the layout is still moving, the frame waits for it.
340
+ useEffect(() => {
341
+ if (!fitted.current) return;
342
+ if (clock.current !== null) reframe.current = true;
343
+ else frame();
344
+ }, [full, full ? null : props.selectedId]);
345
+
319
346
  useEffect(() => {
320
347
  renderer.current?.setTrail(trailRef.current);
321
348
  }, [props.trail]);
@@ -347,6 +374,7 @@ export function Graph(props: GraphProps) {
347
374
  aria-label={SPOTLIGHT_LABEL} spellcheck={false} autoComplete="off" onInput={(event) => props.onQuery?.(event.currentTarget.value)} />
348
375
  {spot === null ? null : (
349
376
  <>
377
+ {props.byAgent === true ? <span class="weave-spotlight-by" title={SPOTLIGHT_BY_AGENT_HINT}>{SPOTLIGHT_BY_AGENT}</span> : null}
350
378
  <span class={`weave-spotlight-count${spot.count === 0 ? " weave-spotlight-none" : ""}`} role="status">{litLabel(spot.count)}</span>
351
379
  <button type="button" class="weave-spotlight-clear" title={SPOTLIGHT_CLEAR_HINT} aria-label={SPOTLIGHT_CLEAR_HINT}
352
380
  onClick={() => { props.onQuery?.(""); field.current?.focus(); }}>{SPOTLIGHT_CLEAR}</button>
@@ -545,6 +545,47 @@ export function frameBox(points: readonly Point[]): ViewBox | null {
545
545
  return { x: [minX - padding, maxX + padding], y: [minY - padding, maxY + padding] };
546
546
  }
547
547
 
548
+ /** Where sigma's camera looks, in the unit square a {@link ViewBox} is normalized onto. */
549
+ export interface CameraTarget {
550
+ readonly x: number;
551
+ readonly y: number;
552
+ /** Sigma's zoom: 1 shows the whole box, smaller is closer. */
553
+ readonly ratio: number;
554
+ }
555
+
556
+ /** Room a focus leaves around what it frames, as a multiple of its span. */
557
+ export const FOCUS_MARGIN = 1.2;
558
+
559
+ /** The smallest span a focus frames, so a note with no links is not blown up to fill the card. */
560
+ export const FOCUS_MIN_SPAN = COLLIDE_RADIUS * 10;
561
+
562
+ /**
563
+ * The camera that frames `points` in a view frozen on `box`.
564
+ *
565
+ * The Context card's note graph is read while you are on a note, so it opens
566
+ * on that note and its neighbours instead of the whole vault. This is the
567
+ * arithmetic. Sigma normalizes the box onto a unit square centred on 0.5 (its
568
+ * longer side becomes 1), and a camera ratio of 1 shows that whole square. So
569
+ * the camera's centre is the points' centre in that square, and its ratio is
570
+ * their padded span as a share of the box. Never above 1: a focus does not
571
+ * zoom out past the fit.
572
+ *
573
+ * `null` when there is nothing to frame: no box, or no finite point.
574
+ */
575
+ export function focusCamera(box: ViewBox | null, points: readonly Point[]): CameraTarget | null {
576
+ const around = frameBox(points);
577
+ if (box === null || around === null) return null;
578
+ const extent = Math.max(box.x[1] - box.x[0], box.y[1] - box.y[0]);
579
+ const scale = extent > 0 && Number.isFinite(extent) ? extent : 1;
580
+ const span = Math.max(around.x[1] - around.x[0], around.y[1] - around.y[0], FOCUS_MIN_SPAN);
581
+ const centre = (range: readonly [number, number]): number => (range[0] + range[1]) / 2;
582
+ return {
583
+ x: 0.5 + (centre(around.x) - centre(box.x)) / scale,
584
+ y: 0.5 + (centre(around.y) - centre(box.y)) / scale,
585
+ ratio: Math.min(1, (span * FOCUS_MARGIN) / scale),
586
+ };
587
+ }
588
+
548
589
  /** The empty graph. What the column renders before the first payload lands. */
549
590
  export const EMPTY_RENDER_GRAPH: RenderGraph = { nodes: [], edges: [] };
550
591
 
@@ -1,7 +1,7 @@
1
1
  import type { GraphTheme } from "../../shared/themes";
2
2
  import type { Point } from "../../shared/layout";
3
- import type { EdgeDisplayOverride, GraphSettings, HighlightStyle, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, TrailGlow, ViewBox } from "./graph.model";
4
- import { edgeReducer, fadeStep, frameBox, graphSettings, labelThreshold, nodeReducer, trailOverride } from "./graph.model";
3
+ import type { CameraTarget, EdgeDisplayOverride, GraphSettings, HighlightStyle, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, TrailGlow, ViewBox } from "./graph.model";
4
+ import { edgeReducer, fadeStep, focusCamera, frameBox, graphSettings, labelThreshold, nodeReducer, trailOverride } from "./graph.model";
5
5
  import type { ProjectedGraph } from "./project";
6
6
  import { positionsOf, project, syncPositions } from "./project";
7
7
 
@@ -68,7 +68,7 @@ export interface SigmaLike {
68
68
  */
69
69
  setCustomBBox(box: ViewBox | null): unknown;
70
70
  refresh(): unknown;
71
- getCamera(): { animatedReset(): Promise<void> };
71
+ getCamera(): { animatedReset(): Promise<void>; animate(state: CameraTarget): Promise<void> };
72
72
  kill(): void;
73
73
  }
74
74
 
@@ -169,6 +169,20 @@ export function sigmaRenderer(
169
169
  /** The box to freeze the view on, from the graph's *current* positions. */
170
170
  const boxOf = (): ViewBox | null => frameBox([...positionsOf(graph).values()]);
171
171
 
172
+ /**
173
+ * Repaint once a camera move lands. Sigma leaves edges out of frames drawn
174
+ * while the camera moves (`hideEdgesOnMove`) and repaints when the camera
175
+ * changes; a move onto where the camera already is changes nothing, so the
176
+ * frame drawn during it, without edges, stayed on screen until something
177
+ * else repainted. A renderer destroyed mid-move paints nothing, and a
178
+ * rejection from a camera killed mid-flight is noise.
179
+ */
180
+ const settle = (instance: SigmaLike, move: Promise<void>): void => {
181
+ move.then(() => {
182
+ if (sigma === instance) instance.refresh();
183
+ }, () => {});
184
+ };
185
+
172
186
  /**
173
187
  * Push the current highlight into sigma's reducers (§7.4).
174
188
  *
@@ -346,14 +360,24 @@ export function sigmaRenderer(
346
360
  const instance = sigma;
347
361
  if (instance === null) return;
348
362
  instance.setCustomBBox(boxOf());
349
- // Sigma leaves edges out of frames drawn while the camera moves
350
- // (`hideEdgesOnMove`) and repaints when the camera changes. A reset onto
351
- // where the camera already is changes nothing, so the frame drawn during
352
- // it, without edges, stayed on screen until something else repainted.
353
- // One refresh once it settles draws them back.
354
- instance.getCamera().animatedReset().then(() => {
355
- if (sigma === instance) instance.refresh();
356
- }, () => {});
363
+ settle(instance, instance.getCamera().animatedReset());
364
+ },
365
+
366
+ /**
367
+ * Frame these nodes instead of the whole graph — the Context card's note
368
+ * graph opening on the note you are reading (`focusCamera`). Re-frames on
369
+ * the current positions first, like `fit`, so the camera and the box
370
+ * agree. Nothing to frame (`null`, or no drawn node among `ids`) is a fit.
371
+ */
372
+ focus(ids: ReadonlySet<string> | null) {
373
+ const instance = sigma;
374
+ if (instance === null) return;
375
+ const box = boxOf();
376
+ const at = positionsOf(graph);
377
+ const points = [...(ids ?? [])].flatMap((id) => at.get(id) ?? []);
378
+ const target = focusCamera(box, points);
379
+ instance.setCustomBBox(box);
380
+ settle(instance, target === null ? instance.getCamera().animatedReset() : instance.getCamera().animate(target));
357
381
  },
358
382
 
359
383
  positions() {
@@ -47,6 +47,10 @@ export const SPOTLIGHT_LABEL = "Light up in graph";
47
47
  export const SPOTLIGHT_CLEAR = "Esc";
48
48
  export const SPOTLIGHT_CLEAR_HINT = "Clear the spotlight (Esc)";
49
49
 
50
+ /** The tag on the field while it shows the agent's newest search, and its hint. */
51
+ export const SPOTLIGHT_BY_AGENT = "agent";
52
+ export const SPOTLIGHT_BY_AGENT_HINT = "Your agent searched for this. Type to light up something else.";
53
+
50
54
  /** The legend while a spotlight is up, replacing the neighbourhood's. */
51
55
  export const SPOTLIGHT_LEGEND = { lit: "lit", dimmed: "not lit" } as const;
52
56
 
@@ -20,7 +20,7 @@ import type { WorkspaceHandle } from "../workspace";
20
20
  import { ContextRail } from "./ContextRail";
21
21
  import { contextCount, contextModel } from "../context/context.model";
22
22
  import { startTrailPoll } from "../trail/trail";
23
- import { TRAIL_TICK_MS, trailLive, trailMarks, trailTurns } from "../trail/trail.model";
23
+ import { TRAIL_TICK_MS, agentSearch, agentSpotlight, trailLive, trailMarks, trailTurns } from "../trail/trail.model";
24
24
  import type { TrailReplay } from "../trail/trail.model";
25
25
  import { ResizeHandle } from "./ResizeHandle";
26
26
  import { deeplinkSelection, formatHash } from "./deeplink.model";
@@ -142,6 +142,9 @@ export function Shell(props: ShellProps) {
142
142
  // What the search palette listed when it lit the graph up, for that query.
143
143
  // Typing a different query in the spotlight leaves these behind.
144
144
  const [graphFound, setGraphFound] = useState<{ query: string; ids: readonly string[] } | null>(null);
145
+ // The agent search the spotlight is showing (its trail `seq`), or `null`
146
+ // when the spotlight is the person's — see `agentSpotlight`.
147
+ const [agentLit, setAgentLit] = useState<number | null>(null);
145
148
  const draggedTab = useRef<string | null>(null);
146
149
  const [dropTab, setDropTab] = useState<{ paneId: string; before: string | null } | null>(null);
147
150
  const [dropPane, setDropPane] = useState<string | null>(null);
@@ -228,8 +231,10 @@ export function Shell(props: ShellProps) {
228
231
  const lightUp = (query: string, ids: readonly string[] = []): void => {
229
232
  setGraphQuery(query);
230
233
  setGraphFound({ query, ids });
234
+ setAgentLit(null);
231
235
  showGraph();
232
236
  };
237
+ const typeSpotlight = (query: string): void => { setGraphQuery(query); setAgentLit(null); };
233
238
  live.current = { layout, overlay, selectedId, graphPreviewId, select };
234
239
  const mayMutate = (slugs: readonly string[]): boolean => mayMutateDrafts(drafts, slugs, () => window.confirm("Discard unsaved changes to the affected notes before changing files?"));
235
240
  const save: SaveNote = async (slug, body, options) => {
@@ -323,6 +328,17 @@ export function Shell(props: ShellProps) {
323
328
  }, [trailGlowing]);
324
329
  const glows = useMemo(() => trailMarks(trail, trailNow, replay), [trail, trailNow, replay]);
325
330
  const turns = useMemo(() => trailTurns(trail, data.graph), [trail, data.graph]);
331
+ // The agent's searches light up in the graph view's spotlight, like the
332
+ // palette's "light up in graph", without switching to the graph.
333
+ const search = useMemo(() => agentSearch(trail), [trail]);
334
+ useEffect(() => {
335
+ const composing = graphQuery.trim() !== "" && document.activeElement?.classList.contains("weave-spotlight-input") === true;
336
+ const next = agentSpotlight(agentLit, search, composing);
337
+ if (next === null) return;
338
+ setGraphQuery(next.query);
339
+ setGraphFound({ query: next.query, ids: next.ids });
340
+ setAgentLit(next.agent);
341
+ }, [search?.seq]);
326
342
  // A floating card stays out of the way when it has nothing to say (no
327
343
  // selection, no agent turn), and over the graph, whose own sheet is its context.
328
344
  const coveredPane = layout.split === "down" ? layout.panes[0] : layout.panes.at(-1);
@@ -514,7 +530,7 @@ export function Shell(props: ShellProps) {
514
530
  </nav>
515
531
  <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">
516
532
  <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>
517
- {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} />}
533
+ {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} />}
518
534
  <div class="weave-vault-label"><span>Workspace</span><strong title={props.cwd}>{props.cwd.split(/[\\/]/).filter(Boolean).pop() ?? "Weave"}</strong></div>
519
535
  </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>
520
536
  <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)` } : {}}>
@@ -535,7 +551,7 @@ export function Shell(props: ShellProps) {
535
551
  aria-hidden={!graphVisible} inert={!graphVisible} role={contextGraphActive ? "region" : undefined} aria-label={contextGraphActive ? "Note graph" : undefined}
536
552
  style={{ ...graphBox, visibility: graphVisible ? "visible" : "hidden", pointerEvents: graphVisible ? "auto" : "none" }}>
537
553
  <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}
538
- {...(contextGraphActive ? {} : { query: graphQuery, ...(graphFound?.query === graphQuery ? { found: graphFound.ids } : {}), onQuery: setGraphQuery, onOpenSplit: openGraphNodeInSplit })} now={now} />
554
+ {...(contextGraphActive ? {} : { query: graphQuery, ...(graphFound?.query === graphQuery ? { found: graphFound.ids } : {}), byAgent: agentLit !== null, onQuery: typeSpotlight, onOpenSplit: openGraphNodeInSplit })} now={now} />
539
555
  </div> : null}
540
556
  </div>
541
557
  <div class="weave-footer"><StatusBar model={statusBarModel(props.cwd, selectedId, searchShortcut(looksApple(props.platform)))} />
@@ -131,7 +131,7 @@ export const ICONS: Readonly<Record<IconName, IconDef>> = {
131
131
  /** An open loop with its arrowhead. */
132
132
  refresh: { d: ["M13 8a5 5 0 1 1-1.5-3.55", "M13 2.75V5.5h-2.75"], filled: false },
133
133
  /** A hub with eight spokes. */
134
- settings: { d: ["M10 8a2 2 0 1 1-4 0a2 2 0 1 1 4 0Z", "M8 1.75v1.5M8 12.75v1.5M1.75 8h1.5M12.75 8h1.5M3.6 3.6l1.05 1.05M11.35 11.35l1.05 1.05M3.6 12.4l1.05-1.05M11.35 4.65l1.05-1.05"], filled: false },
134
+ settings: { d: ["M6.50 3.65L6.89 1.70L9.11 1.70L9.50 3.65A4.6 4.6 0 0 1 11.02 4.53L12.90 3.89L14.01 5.81L12.52 7.12A4.6 4.6 0 0 1 12.52 8.88L14.01 10.19L12.90 12.11L11.02 11.47A4.6 4.6 0 0 1 9.50 12.35L9.11 14.30L6.89 14.30L6.50 12.35A4.6 4.6 0 0 1 4.98 11.47L3.10 12.11L1.99 10.19L3.48 8.88A4.6 4.6 0 0 1 3.48 7.12L1.99 5.81L3.10 3.89L4.98 4.53A4.6 4.6 0 0 1 6.50 3.65Z", "M10 8a2 2 0 1 1-4 0a2 2 0 1 1 4 0Z"], filled: false },
135
135
  /** A light theme. */
136
136
  sun: { d: ["M10.5 8a2.5 2.5 0 1 1-5 0a2.5 2.5 0 1 1 5 0Z", "M8 1.75V3M8 13v1.25M1.75 8H3M13 8h1.25M3.6 3.6l.9.9M11.5 11.5l.9.9M3.6 12.4l.9-.9M11.5 4.5l.9-.9"], filled: false },
137
137
  /** A dark theme. */
@@ -164,7 +164,11 @@ body{font-size:var(--weave-px-base)}
164
164
  provenance glyph shape carries the distinction the colour swap drops;
165
165
  elsewhere the hues are unaffected. The palette's hit rows need the same
166
166
  remap: same selected ground, same failure. The label is restated last
167
- so the selected row is always readable. */
167
+ so the selected row is always readable. The ground itself is the Context
168
+ rail's selection: the --weave-new tint plus a 2px inset accent bar, kept
169
+ under the pointer too. It is the only tinted row: the tree is where you
170
+ are, not what the agent touched (the graph shows that). */
171
+ .weave-row-on,.weave-row-on:hover{background:var(--weave-new);box-shadow:inset 2px 0 0 var(--weave-accent)}
168
172
  .weave-row-on .weave-twisty,.weave-row-on .weave-kind,.weave-row-on .weave-prov,
169
173
  .weave-row-on .weave-meta,.weave-row-on .weave-label{color:var(--weave-fg)}
170
174
  .weave-hit-on .weave-hit-icon,.weave-hit-on .weave-hit-detail,.weave-hit-on .weave-hit-meta{color:var(--weave-fg)}
@@ -179,16 +183,6 @@ body{font-size:var(--weave-px-base)}
179
183
  40%{font-weight:700}
180
184
  100%{background:transparent;font-weight:400}
181
185
  }
182
- /* Recall trail (issue #78): a note the agent searched, read or wrote. The
183
- tint follows the graph's palette for the same kinds (warn → ok → accent)
184
- and steps down through three strength buckets as the glow fades. */
185
- .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)}
186
- .weave-row-trail-read{--weave-trail:var(--weave-ok)}
187
- .weave-row-trail-wrote{--weave-trail:var(--weave-accent)}
188
- .weave-row-trail-3{--weave-trail-mix:90%}
189
- .weave-row-trail-2{--weave-trail-mix:55%}
190
- .weave-row-trail-1{--weave-trail-mix:25%}
191
- .weave-row-trail-wrote.weave-row-trail-3 .weave-label{font-weight:600}
192
186
  .weave-twisty{width:16px;height:16px;flex:none;display:inline-flex;align-items:center;justify-content:center;color:var(--weave-faint);cursor:pointer}
193
187
  .weave-kind{flex:none;color:var(--weave-faint);font-size:var(--weave-px-ui)}
194
188
  .weave-prov{flex:none;font-size:var(--weave-px-prov)}
@@ -602,6 +596,7 @@ body{font-size:var(--weave-px-base)}
602
596
  color:var(--weave-warn);background:color-mix(in srgb,var(--weave-warn) 16%,transparent);border-radius:999px;
603
597
  }
604
598
  .weave-spotlight-none{color:var(--weave-dim);background:var(--weave-line)}
599
+ .weave-spotlight-by{flex:none;font-size:var(--weave-px-caption);color:var(--weave-dim);white-space:nowrap}
605
600
  .weave-spotlight .weave-spotlight-clear{
606
601
  flex:none;padding:1px 5px;cursor:pointer;font-family:var(--weave-mono);font-size:var(--weave-px-prov);
607
602
  color:var(--weave-faint);background:transparent;border:1px solid var(--weave-line-strong);border-radius:var(--weave-radius);
@@ -693,9 +688,8 @@ body{font-size:var(--weave-px-base)}
693
688
  .weave-trail{padding-top:6px;padding-bottom:6px;border-bottom:1px solid var(--weave-line)}
694
689
  .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}
695
690
  .weave-trail-head .weave-ctx-count{margin-left:0}
696
- .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}
697
- .weave-trail-replay{margin-left:auto}
698
- .weave-trail-replay:hover,.weave-trail-turn:hover{color:var(--weave-fg)}
691
+ .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}
692
+ .weave-trail-turn:hover{color:var(--weave-fg)}
699
693
  .weave-trail-turn-on{color:var(--weave-accent);border-color:var(--weave-accent)}
700
694
  .weave-trail-steps{margin:4px 0 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:2px}
701
695
  .weave-trail-step{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px;padding-left:10px;position:relative;line-height:1.45}
@@ -27,7 +27,6 @@ export function TrailStrip(props: TrailStripProps) {
27
27
  <h4 class="weave-ctx-head weave-trail-head">
28
28
  <span class="weave-trail-title">{turnHeading(shown, shown === latest)}</span>
29
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
30
  </h4>
32
31
  {shown.steps.length === 0 ? <p class="weave-trail-empty">{TRAIL_EMPTY_TURN}</p> : null}
33
32
  <ol class="weave-trail-steps">
@@ -9,6 +9,8 @@
9
9
  * steps one after another from the moment it was clicked.
10
10
  * - {@link trailTurns}: the "This turn" strip — each turn's steps in order,
11
11
  * worded as `searched "auth" → read Auth migration → appended Auth migration`.
12
+ * - {@link agentSearch}: the agent's newest search this turn, which the
13
+ * graph view lights up in its spotlight ({@link agentSpotlight}).
12
14
  *
13
15
  * Pure, DOM-free and clock-free (every function takes `now`), like the other
14
16
  * `*.model.ts` files, so the whole feature is covered by unit tests.
@@ -179,17 +181,70 @@ export function trailTurns(trail: TrailPayload | null, graph: GraphPayload | nul
179
181
  return views.slice(0, TRAIL_TURNS_SHOWN);
180
182
  }
181
183
 
182
- /** The strip's heading for a turn: the latest is "This turn", older ones are their time. */
183
184
  /** What the strip says for a turn with no steps yet. */
184
185
  export const TRAIL_EMPTY_TURN = "Nothing recalled yet.";
185
186
 
187
+ /** The strip's heading for a turn: the latest is "This turn", older ones are their time. */
186
188
  export function turnHeading(view: TrailTurnView, latest: boolean): string {
187
189
  return latest ? "This turn" : view.time === "" ? `Turn ${view.turn}` : view.time;
188
190
  }
189
191
 
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}`;
192
+ // --- the agent's search in the graph view's spotlight ----------------------------------
193
+
194
+ /** The agent's newest search this turn: what the graph view's spotlight lights. */
195
+ export interface AgentSearch {
196
+ /** The event's `seq`, so a new search is a new number even for the same words. */
197
+ seq: number;
198
+ query: string;
199
+ /** The notes the search returned, as node ids. */
200
+ ids: readonly string[];
201
+ }
202
+
203
+ /**
204
+ * The agent's newest search in the current turn, or `null`.
205
+ *
206
+ * Only the current turn counts, so the spotlight a search lit goes out when
207
+ * the agent moves on to the next request. A search with no words (the wire
208
+ * allows one) has nothing to spell in the field and is skipped.
209
+ */
210
+ export function agentSearch(trail: TrailPayload | null): AgentSearch | null {
211
+ if (trail === null) return null;
212
+ for (let index = trail.events.length - 1; index >= 0; index--) {
213
+ const event = trail.events[index]!;
214
+ if (event.turn !== trail.turn) return null;
215
+ if (event.kind !== "searched" || event.query === undefined || event.query.trim() === "") continue;
216
+ return { seq: event.seq, query: event.query, ids: event.slugs.map((slug) => `note:${slug}`) };
217
+ }
218
+ return null;
219
+ }
220
+
221
+ /** What the spotlight becomes: its query, the ids lit alongside, and the agent search that set it (`null`: the person's). */
222
+ export interface AgentSpotlight {
223
+ query: string;
224
+ ids: readonly string[];
225
+ agent: number | null;
226
+ }
227
+
228
+ /**
229
+ * How the graph view's spotlight follows the agent's searches, when
230
+ * {@link agentSearch} changes. `null` leaves the spotlight alone.
231
+ *
232
+ * A new search takes the spotlight over, the way the palette's "light up in
233
+ * graph" does, unless the person is in the middle of a query of their own:
234
+ * the field focused with words they typed. A focused field that is empty, or
235
+ * still shows an earlier agent search, is not theirs and follows along, so a
236
+ * field left focused never swallows a search. When the turn that searched is
237
+ * over, the spotlight it lit goes out, unless the person has made it theirs
238
+ * since by typing or lighting something up.
239
+ */
240
+ export function agentSpotlight(
241
+ /** The `seq` of the agent search the spotlight shows now, or `null` when it is the person's (or empty). */
242
+ shown: number | null,
243
+ search: AgentSearch | null,
244
+ /** Whether the field has focus and holds words; `shown` says whose words they are. */
245
+ composing: boolean,
246
+ ): AgentSpotlight | null {
247
+ if (search === null) return shown === null ? null : { query: "", ids: [], agent: null };
248
+ if (search.seq === shown || (composing && shown === null)) return null;
249
+ return { query: search.query, ids: search.ids, agent: search.seq };
195
250
  }
@@ -1,7 +1,5 @@
1
1
  /** The vault and repository tree column. */
2
2
 
3
- import type { TrailMark } from "../trail/trail.model";
4
- import { trailRowClass } from "../trail/trail.model";
5
3
  import { NOTE_DRAG_TYPE } from "../shell/shell.model";
6
4
  import { useLayoutEffect, useRef, useState } from "preact/hooks";
7
5
  import { createFolder, deleteFolder, deleteNote, moveNote, renameFolder, renameNote } from "../api";
@@ -48,8 +46,6 @@ export interface TreeProps {
48
46
  graph: GraphPayload | null;
49
47
  selectedId: string | null;
50
48
  recentIds: ReadonlySet<string>;
51
- /** Recall-trail glows by row id (`trail/trail.model.ts`). */
52
- trail?: ReadonlyMap<string, TrailMark>;
53
49
  onSelect: (id: string, newTab?: boolean, keepSidebar?: boolean) => void;
54
50
  /**
55
51
  * Ask before a mutation that would invalidate an open draft. `false` means
@@ -80,13 +76,13 @@ export function Icon({ name, class: className }: { name: IconName; class?: strin
80
76
  );
81
77
  }
82
78
 
83
- function Row({ view, recentIds, trail, edit, onEdit, onRename, onSelect, onToggle, onMenu, onDrop }: { view: ReturnType<typeof rowViews>[number]; recentIds: ReadonlySet<string>; trail: TrailMark | undefined; edit: string | null; onEdit: (value: string) => void; onRename: (value: string | null) => void; onSelect: (newTab: boolean) => void; onToggle: () => void; onMenu: (x: number, y: number) => void; onDrop: (id: string) => void }) {
79
+ function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMenu, onDrop }: { view: ReturnType<typeof rowViews>[number]; recentIds: ReadonlySet<string>; edit: string | null; onEdit: (value: string) => void; onRename: (value: string | null) => void; onSelect: (newTab: boolean) => void; onToggle: () => void; onMenu: (x: number, y: number) => void; onDrop: (id: string) => void }) {
84
80
  const folder = dropFolder(view.id);
85
81
  return (
86
82
  <li
87
83
  id={view.domId}
88
84
  data-row-id={view.id}
89
- class={`weave-row weave-row-${view.kind}${view.selected ? " weave-row-on" : ""}${recentIds.has(view.id) ? " weave-row-new" : ""}${trailRowClass(trail)}`}
85
+ class={`weave-row weave-row-${view.kind}${view.selected ? " weave-row-on" : ""}${recentIds.has(view.id) ? " weave-row-new" : ""}`}
90
86
  role="treeitem"
91
87
  aria-level={view.level}
92
88
  aria-posinset={view.posinset}
@@ -301,7 +297,7 @@ export function Tree(props: TreeProps) {
301
297
  setMenu({ id: "vault", label: "Vault", x: event.clientX, y: event.clientY });
302
298
  }}
303
299
  >
304
- {rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} trail={props.trail?.get(view.id)} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={(newTab) => { props.onSelect(view.id, newTab); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) { const target = mutableTreeRow(dragged); if (target !== null && !props.onMutate(affectedNoteSlugs(props.graph, target))) return; void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); } }} />)}
300
+ {rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={(newTab) => { props.onSelect(view.id, newTab); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) { const target = mutableTreeRow(dragged); if (target !== null && !props.onMutate(affectedNoteSlugs(props.graph, target))) return; void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); } }} />)}
305
301
  </ul>
306
302
  ) : <p class="weave-tree-empty">{empty}</p>}
307
303
  <p class="weave-tree-count">{rowCountLabel(rows)}</p>