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.
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/web/client/dist/app.js +62 -68
- package/src/web/client/graph/Graph.tsx +32 -4
- package/src/web/client/graph/graph.model.ts +41 -0
- package/src/web/client/graph/renderer.ts +35 -11
- package/src/web/client/graph/spotlight.model.ts +4 -0
- package/src/web/client/shell/Shell.tsx +19 -3
- package/src/web/client/shell/icons.model.ts +1 -1
- package/src/web/client/shell/theme.ts +8 -14
- package/src/web/client/trail/TrailStrip.tsx +0 -1
- package/src/web/client/trail/trail.model.ts +61 -6
- package/src/web/client/tree/Tree.tsx +3 -7
|
@@ -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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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}
|
|
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:
|
|
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: ["
|
|
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-
|
|
697
|
-
.weave-trail-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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,
|
|
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" : ""}
|
|
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}
|
|
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>
|