pi-weave 0.3.9 → 0.3.11

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.
@@ -18,8 +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 { contextCount, contextModel } from "../context/context.model";
21
22
  import { startTrailPoll } from "../trail/trail";
22
- 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";
23
24
  import type { TrailReplay } from "../trail/trail.model";
24
25
  import { ResizeHandle } from "./ResizeHandle";
25
26
  import { deeplinkSelection, formatHash } from "./deeplink.model";
@@ -31,7 +32,7 @@ import { watchKeys } from "./keys";
31
32
  import { COLUMN_FOCUS_SELECTORS, TREE_FILTER_SELECTOR, focusSelector, runShellAction } from "./keys.model";
32
33
  import { StatusBar } from "./StatusBar";
33
34
  import type { OverlayId } from "./shell.model";
34
- import { NOTE_DRAG_TYPE, noteDropId, TICK_MS, graphClickOpensTab, looksApple, recentEntries, recordVisit, searchHint, searchShortcut, statusBarModel } from "./shell.model";
35
+ import { NOTE_DRAG_TYPE, noteDropId, TICK_MS, graphClickOpensTab, looksApple, openInSplit, recentEntries, recordVisit, searchHint, searchShortcut, statusBarModel } from "./shell.model";
35
36
  import { toggleTheme, effectiveScheme, loadTheme, saveTheme, themeButton } from "./theme.model";
36
37
 
37
38
  export interface ShellProps { cwd: string; initialWidth: number; platform: string }
@@ -135,6 +136,15 @@ export function Shell(props: ShellProps) {
135
136
  const [graphSelection, setGraphSelection] = useState<string | null>(null);
136
137
  const lastViewedDocument = useRef<string | null>(null);
137
138
  const [graphPreviewId, setGraphPreviewId] = useState<string | null>(null);
139
+ // The graph spotlight's query (`graph/spotlight.model.ts`). Held here, not
140
+ // in the column, so other UI can light the graph up — see `lightUp`.
141
+ const [graphQuery, setGraphQuery] = useState("");
142
+ // What the search palette listed when it lit the graph up, for that query.
143
+ // Typing a different query in the spotlight leaves these behind.
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);
138
148
  const draggedTab = useRef<string | null>(null);
139
149
  const [dropTab, setDropTab] = useState<{ paneId: string; before: string | null } | null>(null);
140
150
  const [dropPane, setDropPane] = useState<string | null>(null);
@@ -162,6 +172,11 @@ export function Shell(props: ShellProps) {
162
172
  const fullGraphVisible = graphPane !== undefined && (width >= 850 || graphPane.id === layout.activePane);
163
173
  const contextGraphActive = contextGraph && tab.kind === "document" && !fullGraphVisible;
164
174
  const contextVisible = width < 1050 ? compactContext : layout.contextVisible;
175
+ // Context floats as a card over the page's right margin unless pinned as a column (C1).
176
+ const contextFloating = !layout.contextPinned;
177
+ const contextTotal = useMemo(() => contextCount(contextModel(data.graph, selectedId)), [data.graph, selectedId]);
178
+ const cardWidth = Math.max(240, Math.min(320, layout.contextWidth));
179
+ const showContext = (): void => { if (width < 1050) setCompactContext(true); else setLayout({ ...layout, contextVisible: true }); };
165
180
  const graphVisible = fullGraphVisible || contextGraphActive && contextVisible && documentId !== null;
166
181
  const hasGraph = layout.panes.some((group) => group.tabs.some((entry) => entry.kind === "graph"));
167
182
  const live = useRef({ layout, overlay, selectedId, graphPreviewId, select: (_id: string | null) => {} });
@@ -203,11 +218,29 @@ export function Shell(props: ShellProps) {
203
218
  if (graphClickOpensTab(live.current.graphPreviewId, id)) openGraphNode(id);
204
219
  else previewGraphNode(id);
205
220
  };
221
+ /** The graph sheet's "Open in split": beside the graph, which stays on screen. */
222
+ const openGraphNodeInSplit = (id: string): void => {
223
+ previewGraphNode(null);
224
+ change(openInSplit(layout, id, graphPane?.id));
225
+ };
226
+ /**
227
+ * Open the graph view with `query` lit up in its spotlight. The entry point
228
+ * for other UI (the search palette's "light up in graph"); `""` clears it.
229
+ * `ids` are the palette's results, so a note found by its text lights too.
230
+ */
231
+ const lightUp = (query: string, ids: readonly string[] = []): void => {
232
+ setGraphQuery(query);
233
+ setGraphFound({ query, ids });
234
+ setAgentLit(null);
235
+ showGraph();
236
+ };
237
+ const typeSpotlight = (query: string): void => { setGraphQuery(query); setAgentLit(null); };
206
238
  live.current = { layout, overlay, selectedId, graphPreviewId, select };
207
239
  const mayMutate = (slugs: readonly string[]): boolean => mayMutateDrafts(drafts, slugs, () => window.confirm("Discard unsaved changes to the affected notes before changing files?"));
208
240
  const save: SaveNote = async (slug, body, options) => {
209
241
  const result = await saveNote(fetchJson, slug, body, options?.raw === true);
210
- if (!result.ok) { window.alert(result.message); return false; }
242
+ // An autosave is quiet: the edit bar says "Not saved" instead of an alert per pause.
243
+ if (!result.ok) { if (options?.quiet !== true) window.alert(result.message); return false; }
211
244
  setRevision((value) => value + 1); workspace.current?.refresh(); return true;
212
245
  };
213
246
  const create = async (title: string, paneId: string): Promise<boolean> => {
@@ -295,6 +328,23 @@ export function Shell(props: ShellProps) {
295
328
  }, [trailGlowing]);
296
329
  const glows = useMemo(() => trailMarks(trail, trailNow, replay), [trail, trailNow, replay]);
297
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]);
342
+ // A floating card stays out of the way when it has nothing to say (no
343
+ // selection, no agent turn), and over the graph, whose own sheet is its context.
344
+ const coveredPane = layout.split === "down" ? layout.panes[0] : layout.panes.at(-1);
345
+ const graphUnderCard = coveredPane?.tabs.find((entry) => entry.id === coveredPane.activeTab)?.kind === "graph";
346
+ const contextIdle = contextFloating && (graphUnderCard || selectedId === null && turns.length === 0);
347
+ const contextOpen = contextVisible && !contextIdle;
298
348
  const replayTurn = (turn: number): void => { const at = Date.now(); setTrailNow(at); setReplay({ turn, startedAt: at }); };
299
349
  useEffect(() => {
300
350
  const tick = window.setInterval(() => setNow(Date.now()), TICK_MS);
@@ -329,6 +379,10 @@ export function Shell(props: ShellProps) {
329
379
  },
330
380
  closeTab: () => { const current = live.current.layout; change(closeTab(current, current.activePane, activeTab(current).id)); },
331
381
  toggleTheme: () => setLayout((current) => ({ ...current, theme: toggleTheme(current.theme, schemeOf(window), current.preferences) })),
382
+ toggleContext: () => {
383
+ if (window.innerWidth < 1050) setCompactContext((value) => !value);
384
+ else setLayout((current) => ({ ...current, contextVisible: !current.contextVisible }));
385
+ },
332
386
  focusSelector: (selector) => {
333
387
  if (selector === COLUMN_FOCUS_SELECTORS.graph) setLayout((current) => ({ ...openGraph(current), ...(window.innerWidth < 850 ? { treeVisible: false } : {}) }));
334
388
  else if (selector === COLUMN_FOCUS_SELECTORS.tree || selector === TREE_FILTER_SELECTOR) setLayout((current) => ({ ...current, treeVisible: true }));
@@ -400,7 +454,9 @@ export function Shell(props: ShellProps) {
400
454
  const renderPane = (group: Pane) => {
401
455
  const current = group.tabs.find((entry) => entry.id === group.activeTab)!;
402
456
  const isActive = group.id === layout.activePane;
403
- return <section key={group.id} class={`weave-pane${isActive ? " weave-pane-active" : ""}${dropPane === group.id ? " weave-pane-drop" : ""}`} data-drop-label={draggedTab.current === null ? "Open note here" : "Move tab here"} aria-label={`Workspace pane ${layout.panes.indexOf(group) + 1}`}
457
+ // The floating card sits over the right-hand pane (both, stacked); its text makes room.
458
+ const underCard = contextFloating && contextOpen && (layout.split === "down" || group === layout.panes.at(-1));
459
+ return <section key={group.id} class={`weave-pane${isActive ? " weave-pane-active" : ""}${dropPane === group.id ? " weave-pane-drop" : ""}${underCard ? " weave-pane-under-card" : ""}`} data-drop-label={draggedTab.current === null ? "Open note here" : "Move tab here"} aria-label={`Workspace pane ${layout.panes.indexOf(group) + 1}`}
404
460
  onDragOver={(event) => tabDragOver(group.id, event)} onDrop={(event) => tabDrop(group.id, event)}
405
461
  onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropPane(null); }}
406
462
  hidden={width < 850 && !isActive} onFocusCapture={() => { if (!isActive) setLayout((value) => ({ ...value, activePane: group.id })); }}
@@ -431,7 +487,7 @@ export function Shell(props: ShellProps) {
431
487
  const index = event.key === "Home" ? 0 : event.key === "End" ? group.tabs.length - 1 : (group.tabs.indexOf(entry) + offset + group.tabs.length) % group.tabs.length;
432
488
  const next = group.tabs[index]!; setLayout(activateTab(layout, group.id, next.id));
433
489
  requestAnimationFrame(() => document.getElementById(`tab-${next.id}`)?.focus());
434
- }}><span aria-hidden="true">{entry.kind === "graph" ? "◌" : "▤"}</span><span>{titleOf(entry, data.graph)}</span>
490
+ }}><span aria-hidden="true"><Icon name={entry.kind === "graph" ? "graph" : "note"} class="weave-icon" /></span><span>{titleOf(entry, data.graph)}</span>
435
491
  {drafts.isDirty((tabSelection(entry) ?? "").slice(5)) ? <span class="weave-dirty" aria-label="Unsaved changes">●</span> : null}</button>
436
492
  <button type="button" class="weave-tab-close" aria-label={`Close ${titleOf(entry, data.graph)}`} onClick={() => change(closeTab(layout, group.id, entry.id))}>×</button>
437
493
  </div>)}
@@ -460,23 +516,21 @@ export function Shell(props: ShellProps) {
460
516
  };
461
517
 
462
518
  return <>
463
- <div class="weave-workbench" style={{ "--weave-note-size": `${layout.preferences.fontSize}px`, "--weave-reading-width": layout.preferences.readable ? "760px" : "100%" }} ref={root} onDragEnd={() => { draggedTab.current = null; setDropPane(null); setDropTab(null); }}>
519
+ <div class="weave-workbench" style={{ "--weave-note-size": `${layout.preferences.fontSize}px`, "--weave-reading-width": layout.preferences.readable ? "760px" : "100%", "--weave-card-width": `${cardWidth}px` }} ref={root} onDragEnd={() => { draggedTab.current = null; setDropPane(null); setDropTab(null); }}>
464
520
  <nav class="weave-ribbon" aria-label="Workspace tools">
465
- <button type="button" aria-label="Toggle notes sidebar" aria-pressed={layout.treeVisible} title="Notes" onClick={() => setLayout({ ...layout, treeVisible: !layout.treeVisible })}>▤</button>
466
- <button type="button" aria-label="Open graph view" title="Graph view" onClick={showGraph}>◌</button>
467
- <button type="button" aria-label="Toggle note graph" title="Note graph" aria-pressed={contextGraphActive && contextVisible} onClick={toggleContextGraph}><svg viewBox="0 0 20 20" width={18} height={18} fill="none" stroke="currentColor" stroke-width={1.5} aria-hidden="true"><rect x="2" y="2" width="16" height="16" rx="3" /><circle cx="10" cy="10" r="4" /></svg></button>
468
- <button type="button" aria-label="Search notes and repository" title={searchHint(searchShortcut(looksApple(props.platform)))} onClick={() => setOverlay("search")}>
469
- <svg viewBox="0 0 20 20" width={16} height={16} fill="none" stroke="currentColor" stroke-width={1.7} aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5" /><path d="m13 13 4 4" /></svg>
470
- </button>
521
+ <button type="button" aria-label="Toggle notes sidebar" aria-pressed={layout.treeVisible} title="Notes" onClick={() => setLayout({ ...layout, treeVisible: !layout.treeVisible })}><Icon name="sidebar" class="weave-icon" /></button>
522
+ <button type="button" aria-label="Open graph view" title="Graph view" onClick={showGraph}><Icon name="graph" class="weave-icon" /></button>
523
+ <button type="button" aria-label="Toggle note graph" title="Note graph" aria-pressed={contextGraphActive && contextVisible} onClick={toggleContextGraph}><Icon name="noteGraph" class="weave-icon" /></button>
524
+ <button type="button" aria-label="Search notes and repository" title={searchHint(searchShortcut(looksApple(props.platform)))} onClick={() => setOverlay("search")}><Icon name="search" class="weave-icon" /></button>
471
525
  <span class="weave-ribbon-space" />
472
- <button type="button" aria-label="Toggle context sidebar" aria-pressed={contextVisible} title="Context" onClick={() => width < 1050 ? setCompactContext(!compactContext) : setLayout({ ...layout, contextVisible: !layout.contextVisible })}>☷</button>
473
- <button type="button" aria-label="Refresh workspace" title="Refresh workspace" onClick={() => { workspace.current?.refresh(); setRevision((value) => value + 1); }}>↻</button>
474
- <button type="button" aria-label={theme.hint} title={theme.hint} onClick={() => setLayout({ ...layout, theme: toggleTheme(layout.theme, scheme, layout.preferences) })}>{theme.glyph}</button>
475
- <button type="button" aria-label="Settings" title="Settings" onClick={() => setOverlay("settings")}>⚙</button>
526
+ <button type="button" aria-label="Toggle context sidebar" aria-pressed={contextVisible} title="Context" onClick={() => width < 1050 ? setCompactContext(!compactContext) : setLayout({ ...layout, contextVisible: !layout.contextVisible })}><Icon name="context" class="weave-icon" /></button>
527
+ <button type="button" aria-label="Refresh workspace" title="Refresh workspace" onClick={() => { workspace.current?.refresh(); setRevision((value) => value + 1); }}><Icon name="refresh" class="weave-icon" /></button>
528
+ <button type="button" aria-label={theme.hint} title={theme.hint} onClick={() => setLayout({ ...layout, theme: toggleTheme(layout.theme, scheme, layout.preferences) })}><Icon name={theme.icon} class="weave-icon" /></button>
529
+ <button type="button" aria-label="Settings" title="Settings" onClick={() => setOverlay("settings")}><Icon name="settings" class="weave-icon" /></button>
476
530
  </nav>
477
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">
478
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>
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} />}
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} />}
480
534
  <div class="weave-vault-label"><span>Workspace</span><strong title={props.cwd}>{props.cwd.split(/[\\/]/).filter(Boolean).pop() ?? "Weave"}</strong></div>
481
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>
482
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)` } : {}}>
@@ -485,7 +539,10 @@ export function Shell(props: ShellProps) {
485
539
  setLayout((value) => ({ ...value, ratio: Math.max(.2, Math.min(.8, value.ratio + delta / (extent ?? 800))) }));
486
540
  }} /> : null}{renderPane(group)}</>)}
487
541
  </main>
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>
542
+ <div class={`weave-sidebar-dock weave-context-dock${contextFloating ? " weave-context-float" : ""}`} data-open={contextOpen} inert={!contextOpen} aria-hidden={!contextOpen} 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 weave-context-heading"><strong>Context</strong><span class="weave-context-count">{contextTotal}</span>
543
+ <button type="button" class="weave-context-pin" aria-pressed={layout.contextPinned} aria-label={layout.contextPinned ? "Float context over the page" : "Dock context as a column"} title={layout.contextPinned ? "Float over the page" : "Dock as a column"} onClick={() => setLayout({ ...layout, contextPinned: !layout.contextPinned })}><Icon name="pin" class="weave-icon" /></button>
544
+ <button type="button" aria-label="Hide context sidebar" title="Fold (])" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle context sidebar"]')?.focus(); setCompactContext(false); setLayout({ ...layout, contextVisible: false }); }}>{contextFloating ? <Icon name="chevron" class="weave-icon weave-icon-open" /> : "»"}</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>
545
+ {contextFloating && !contextVisible && !contextIdle ? <button type="button" class="weave-context-pill" aria-label={`Show context, ${contextTotal} items`} title="Show context (])" onClick={showContext}><Icon name="context" class="weave-icon" />Context<span class="weave-context-count">{contextTotal}</span></button> : null}
489
546
  {hasGraph || contextGraph ? <div class={`weave-graph-host${contextGraphActive ? " weave-context-graph-host" : ""}`}
490
547
  onDragOver={(event) => { if (!contextGraphActive && graphPane) tabDragOver(graphPane.id, event); }} onDrop={(event) => { if (!contextGraphActive && graphPane) tabDrop(graphPane.id, event); }}
491
548
  onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropPane(null); }}
@@ -493,15 +550,16 @@ export function Shell(props: ShellProps) {
493
550
  onFocusCapture={() => { if (!contextGraphActive && graphPane) setLayout((current) => ({ ...current, activePane: graphPane.id })); }}
494
551
  aria-hidden={!graphVisible} inert={!graphVisible} role={contextGraphActive ? "region" : undefined} aria-label={contextGraphActive ? "Note graph" : undefined}
495
552
  style={{ ...graphBox, visibility: graphVisible ? "visible" : "hidden", pointerEvents: graphVisible ? "auto" : "none" }}>
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} />
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}
554
+ {...(contextGraphActive ? {} : { query: graphQuery, ...(graphFound?.query === graphQuery ? { found: graphFound.ids } : {}), byAgent: agentLit !== null, onQuery: typeSpotlight, onOpenSplit: openGraphNodeInSplit })} now={now} />
497
555
  </div> : null}
498
556
  </div>
499
- <div class="weave-footer"><StatusBar model={statusBarModel(props.cwd, selectedId)} />
557
+ <div class="weave-footer"><StatusBar model={statusBarModel(props.cwd, selectedId, searchShortcut(looksApple(props.platform)))} />
500
558
  {persistError ? <span role="status">Workspace restoration unavailable</span> : null}
501
559
  {drafts.dirtySlugs().length ? <span>{drafts.dirtySlugs().length} unsaved</span> : null}
502
560
  {width < 850 && layout.panes.length > 1 ? <button type="button" onClick={() => setLayout({ ...layout, activePane: layout.panes.find((group) => group.id !== pane.id)!.id })}>Switch pane</button> : null}
503
561
  </div>
504
- {overlay === "search" ? <SearchPalette graph={data.graph} onSelect={select} onClose={() => setOverlay(null)} ports={{ fetch: fetchJson }} /> : null}
562
+ {overlay === "search" ? <SearchPalette graph={data.graph} onSelect={select} onLightUp={lightUp} onClose={() => setOverlay(null)} ports={{ fetch: fetchJson }} /> : null}
505
563
  {overlay === "settings" ? <Settings compact={width <= 650} layout={layout} info={info} cwd={props.cwd} shortcut={searchShortcut(looksApple(props.platform))} onChange={(next) => { change(next); setCompactContext(next.contextVisible); }} onRefresh={() => { workspace.current?.refresh(); setRevision((value) => value + 1); }} onClose={() => setOverlay(null)} /> : null}
506
564
  {overlay === "help" ? <HelpOverlay shortcut={searchShortcut(looksApple(props.platform))} onClose={() => setOverlay(null)} /> : null}
507
565
  </>;
@@ -16,6 +16,7 @@ export function StatusBar({ model }: { model: StatusBarModel }) {
16
16
  <span class="weave-status-sel" title="Current item">
17
17
  {model.selection}
18
18
  </span>
19
+ {model.hint === "" ? null : <span class="weave-status-hint">{model.hint}</span>}
19
20
  </footer>
20
21
  );
21
22
  }
@@ -43,7 +43,30 @@ export type IconName =
43
43
  | "entryPoint"
44
44
  | "gitState"
45
45
  | "external"
46
- | "file";
46
+ | "file"
47
+ // Workspace chrome (the ribbon, tabs, the edit bar and the Context card).
48
+ | "sidebar"
49
+ | "graph"
50
+ | "noteGraph"
51
+ | "search"
52
+ | "context"
53
+ | "refresh"
54
+ | "settings"
55
+ | "sun"
56
+ | "moon"
57
+ | "system"
58
+ | "history"
59
+ | "edit"
60
+ | "check"
61
+ | "heading"
62
+ | "bold"
63
+ | "italic"
64
+ | "link"
65
+ | "wikilink"
66
+ | "task"
67
+ | "pin"
68
+ | "close"
69
+ | "folder";
47
70
 
48
71
  /** The box every path is drawn in, and the stroke the sheet's aesthetic asks for. */
49
72
  export const ICON_BOX = 16;
@@ -95,4 +118,48 @@ export const ICONS: Readonly<Record<IconName, IconDef>> = {
95
118
  external: { d: ["M4.5 11.5l7-7", "M6.75 4.5h4.75v4.75"], filled: false },
96
119
  /** A plain repo file, an unfolded page. */
97
120
  file: { d: ["M4.5 2.75h7v10.5h-7Z"], filled: false },
121
+ /** A window with its left panel ruled off: the notes sidebar. */
122
+ sidebar: { d: ["M2.75 3.25h10.5v9.5H2.75Z", "M6.25 3.25v9.5"], filled: false },
123
+ /** Three linked nodes: the graph view. */
124
+ graph: { d: ["M5.5 4a1.5 1.5 0 1 1-3 0a1.5 1.5 0 1 1 3 0Z", "M13.5 5a1.5 1.5 0 1 1-3 0a1.5 1.5 0 1 1 3 0Z", "M8.5 12a1.5 1.5 0 1 1-3 0a1.5 1.5 0 1 1 3 0Z", "M5.5 4.3 10.5 4.8M4.6 5.4l1.9 5.2M11.3 6.3 8.3 10.8"], filled: false },
125
+ /** A framed node: the current note's own graph. */
126
+ noteGraph: { d: ["M3.25 2.75h9.5v10.5h-9.5Z", "M10.5 8a2.5 2.5 0 1 1-5 0a2.5 2.5 0 1 1 5 0Z"], filled: false },
127
+ /** A lens. */
128
+ search: { d: ["M11 7a4 4 0 1 1-8 0a4 4 0 1 1 8 0Z", "m10 10 3.5 3.5"], filled: false },
129
+ /** A window with its right panel ruled off: Context. */
130
+ context: { d: ["M2.75 3.25h10.5v9.5H2.75Z", "M9.75 3.25v9.5"], filled: false },
131
+ /** An open loop with its arrowhead. */
132
+ refresh: { d: ["M13 8a5 5 0 1 1-1.5-3.55", "M13 2.75V5.5h-2.75"], filled: false },
133
+ /** A hub with eight spokes. */
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
+ /** A light theme. */
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
+ /** A dark theme. */
138
+ moon: { d: ["M13 9.75A5.25 5.25 0 0 1 6.25 3a5.25 5.25 0 1 0 6.75 6.75Z"], filled: false },
139
+ /** Follow the system: a circle split down the middle. */
140
+ system: { d: ["M13.25 8a5.25 5.25 0 1 1-10.5 0a5.25 5.25 0 1 1 10.5 0Z", "M8 2.75v10.5"], filled: false },
141
+ /** A clock face: the note's history. */
142
+ history: { d: ["M13.25 8a5.25 5.25 0 1 1-10.5 0a5.25 5.25 0 1 1 10.5 0Z", "M8 5v3l2 1.5"], filled: false },
143
+ /** A pencil. */
144
+ edit: { d: ["M10.75 2.75 13.25 5.25 6 12.5H3.5V10Z", "M9.25 4.25l2.5 2.5"], filled: false },
145
+ /** A tick. */
146
+ check: { d: ["m3.25 8.5 3 3 6.5-6.75"], filled: false },
147
+ /** A heading mark. */
148
+ heading: { d: ["M4 3v10M12 3v10M4 8h8"], filled: false },
149
+ /** Bold. */
150
+ bold: { d: ["M4.5 3h4a2.5 2.5 0 0 1 0 5h-4Z", "M4.5 8h4.75a2.5 2.5 0 0 1 0 5H4.5Z"], filled: false },
151
+ /** Italic. */
152
+ italic: { d: ["M7 3h5M4 13h5M10 3 6 13"], filled: false },
153
+ /** Two chain links. */
154
+ link: { d: ["M6.75 9.25l2.5-2.5", "M7.25 4.75 8.5 3.5a2.5 2.5 0 0 1 3.5 3.5l-1.25 1.25", "M8.75 11.25 7.5 12.5a2.5 2.5 0 0 1-3.5-3.5l1.25-1.25"], filled: false },
155
+ /** Square brackets: a [[wikilink]]. */
156
+ wikilink: { d: ["M5.5 3H3.5v10h2", "M10.5 3h2v10h-2"], filled: false },
157
+ /** A ticked box: a task line. */
158
+ task: { d: ["M3 3h10v10H3Z", "m5.5 8 1.75 1.75L10.75 6.25"], filled: false },
159
+ /** A pushpin: dock the Context card. */
160
+ pin: { d: ["M6 2.75h4l-.5 4 2 2v1h-7v-1l2-2Z", "M8 9.75v3.5"], filled: false },
161
+ /** A cross. */
162
+ close: { d: ["m4.25 4.25 7.5 7.5M11.75 4.25l-7.5 7.5"], filled: false },
163
+ /** A folder tab, for the crumb above a note's title. */
164
+ folder: { d: ["M2.75 4.25h3.5l1.25 1.5h5.75v6.5H2.75Z"], filled: false },
98
165
  };
@@ -99,7 +99,9 @@ export type ShellAction =
99
99
  | { readonly type: "clearSelection" }
100
100
  | { readonly type: "closeTab" }
101
101
  /** `t` — switch between the selected light and dark themes. */
102
- | { readonly type: "toggleTheme" };
102
+ | { readonly type: "toggleTheme" }
103
+ /** `]` — fold or unfold the Context card. */
104
+ | { readonly type: "toggleContext" };
103
105
 
104
106
  /**
105
107
  * The command-key letters, as data.
@@ -155,6 +157,7 @@ export const BARE_KEYS: Readonly<Record<string, ShellAction>> = {
155
157
  g: { type: "fitGraph" },
156
158
  "?": { type: "openHelp" },
157
159
  t: { type: "toggleTheme" },
160
+ "]": { type: "toggleContext" },
158
161
  };
159
162
 
160
163
  /**
@@ -337,6 +340,8 @@ export interface ShellEffects {
337
340
  closeTab(): void;
338
341
  /** `t` — switch between the user's selected light and dark themes. */
339
342
  toggleTheme(): void;
343
+ /** `]` — fold or unfold the Context card (or column). */
344
+ toggleContext(): void;
340
345
  }
341
346
 
342
347
  /** Perform an action. Total over {@link ShellAction}. */
@@ -363,6 +368,8 @@ export function runShellAction(action: ShellAction, fx: ShellEffects): void {
363
368
  return fx.closeTab();
364
369
  case "toggleTheme":
365
370
  return fx.toggleTheme();
371
+ case "toggleContext":
372
+ return fx.toggleContext();
366
373
  }
367
374
  }
368
375
 
@@ -408,6 +415,7 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
408
415
  })),
409
416
  { combo: "?", what: "This help" },
410
417
  { combo: "t", what: "Switch between selected light and dark themes" },
418
+ { combo: "]", what: "Fold or unfold Context" },
411
419
  { combo: "Esc", what: "Clear the selection from the tree or graph, or close an overlay" },
412
420
  ],
413
421
  },
@@ -441,6 +449,8 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
441
449
  { combo: "↑ / ↓", what: "Move through results" },
442
450
  { combo: "↵", what: "Open the highlighted result" },
443
451
  { combo: `${cmd}↵`, what: "Open the highlighted result in a new tab" },
452
+ { combo: "Tab / ⇧Tab", what: "Switch between All, Notes, Code and Tags" },
453
+ { combo: "⇧↵", what: "Light up the matches in the graph" },
444
454
  { combo: "Esc", what: "Close the palette" },
445
455
  ],
446
456
  },
@@ -15,6 +15,8 @@
15
15
  */
16
16
 
17
17
  import type { GraphPayload, WireNodeKind } from "../../shared/wire";
18
+ import { activePane, moveTab, openDocument } from "../../shared/workspace";
19
+ import type { WorkspaceLayout } from "../../shared/workspace";
18
20
  import { kindIcon } from "../tree/tree.model";
19
21
  import type { IconName } from "./icons.model";
20
22
  export const NOTE_DRAG_TYPE = "application/x-weave-note";
@@ -37,6 +39,39 @@ export function graphClickOpensTab(previewId: string | null, id: string | null):
37
39
  return id !== null && id === previewId;
38
40
  }
39
41
 
42
+ /**
43
+ * Open `id` beside the graph — the graph sheet's "Open in split".
44
+ *
45
+ * With two panes, the note opens as a new tab in the pane that is *not* the
46
+ * graph's. With one, it opens in a new second pane and the graph's pane keeps
47
+ * the graph on screen: the tab is opened in the graph's pane and then moved,
48
+ * which is the same `moveTab` the tab strip's "Split right" uses, so the new
49
+ * pane is an ordinary one. The split direction is left as the layout has it.
50
+ *
51
+ * The note is shown in the split either way, which is what the button asks
52
+ * for; only which pane is active follows the user's `focusNewTabs`
53
+ * preference. Unchanged when the tab budget is spent.
54
+ */
55
+ export function openInSplit(layout: WorkspaceLayout, id: string, graphPaneId: string | undefined): WorkspaceLayout {
56
+ const graph = layout.panes.find((pane) => pane.id === graphPaneId) ?? activePane(layout);
57
+ const other = layout.panes.find((pane) => pane.id !== graph.id);
58
+ const opened = openDocument(layout, id, { newTab: true, paneId: other?.id ?? graph.id });
59
+ if (opened === layout) return layout;
60
+ let next = opened;
61
+ if (other === undefined) {
62
+ // One pane, so it is the graph's, and the new tab is its last.
63
+ const { lastDocumentTab: _opened, ...pane } = opened.panes[0]!;
64
+ const tab = pane.tabs.at(-1)!;
65
+ // Put the graph back in front of its pane before the new tab leaves it, so
66
+ // `moveTab` keeps the graph active there instead of the tab beside it.
67
+ const restored: WorkspaceLayout = { ...opened, panes: [
68
+ { ...pane, activeTab: graph.activeTab, ...(graph.lastDocumentTab === undefined ? {} : { lastDocumentTab: graph.lastDocumentTab }) },
69
+ ] };
70
+ next = moveTab(restored, graph.id, tab.id);
71
+ }
72
+ return layout.preferences.focusNewTabs ? next : { ...next, activePane: graph.id };
73
+ }
74
+
40
75
  export interface RecentEntry {
41
76
  readonly id: string;
42
77
  readonly label: string;
@@ -78,7 +113,10 @@ export const CONTEXT_EMPTY: EmptyStateCopy = {
78
113
  */
79
114
  export interface StatusBarModel {
80
115
  readonly cwd: string;
116
+ /** The selection as a reader names it: a note's file path, not its node id. */
81
117
  readonly selection: string;
118
+ /** The quiet hint at the far end, "⌘K search"; empty when none is given. */
119
+ readonly hint: string;
82
120
  }
83
121
 
84
122
  /** The `—` shown where a value is genuinely absent, not zero. */
@@ -88,13 +126,22 @@ export const NO_VALUE = "—";
88
126
  export function statusBarModel(
89
127
  cwd: string,
90
128
  selectedId: string | null,
129
+ shortcut = "",
91
130
  ): StatusBarModel {
92
131
  return {
93
132
  cwd: cwd === "" ? NO_VALUE : cwd,
94
- selection: selectedId ?? "nothing selected",
133
+ selection: selectionLabel(selectedId),
134
+ hint: shortcut === "" ? "" : `${shortcut} search`,
95
135
  };
96
136
  }
97
137
 
138
+ /** `note:projects/launch` reads as the file it is, `projects/launch.md`. */
139
+ function selectionLabel(selectedId: string | null): string {
140
+ if (selectedId === null) return "nothing selected";
141
+ if (selectedId.startsWith("note:") && selectedId.length > "note:".length) return `${selectedId.slice("note:".length)}.md`;
142
+ return selectedId;
143
+ }
144
+
98
145
  /**
99
146
  * How often the shell re-renders on its own, in ms.
100
147
  *
@@ -60,6 +60,8 @@ export function themeAttr(choice: ThemeChoice): PaletteChoice | null {
60
60
 
61
61
  export interface ThemeButtonView {
62
62
  readonly glyph: "●" | "◐" | "○";
63
+ /** The ribbon's icon for the same state. */
64
+ readonly icon: "system" | "sun" | "moon";
63
65
  readonly label: string;
64
66
  readonly hint: string;
65
67
  }
@@ -71,6 +73,7 @@ function themeName(choice: ThemeChoice): string {
71
73
  export function themeButton(choice: ThemeChoice, systemScheme: ColorScheme = "dark", pair: ThemePair = DEFAULT_PREFERENCES): ThemeButtonView {
72
74
  return {
73
75
  glyph: choice === "system" ? "◐" : THEMES[choice].scheme === "light" ? "○" : "●",
76
+ icon: choice === "system" ? "system" : THEMES[choice].scheme === "light" ? "sun" : "moon",
74
77
  label: themeName(choice),
75
78
  hint: `Colour theme: ${themeName(choice)} — click for ${themeName(toggleTheme(choice, systemScheme, pair))}`,
76
79
  };