pi-weave 0.3.3 → 0.3.5

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,6 +18,7 @@ export function Settings(props: {
18
18
  onChange: (layout: WorkspaceLayout) => void; onRefresh: () => void; onClose: () => void;
19
19
  }) {
20
20
  const [category, setCategory] = useState<typeof CATEGORIES[number]>("General");
21
+ const [refreshCount, setRefreshCount] = useState(0);
21
22
  const trap = useFocusTrap();
22
23
  const p = props.layout.preferences;
23
24
  const update = (values: Partial<Preferences>): void => props.onChange({ ...props.layout, preferences: { ...p, ...values } });
@@ -30,8 +31,8 @@ export function Settings(props: {
30
31
  <section aria-label={`${category} settings`}><h3>{category}</h3>
31
32
  {category === "General" ? <>
32
33
  <p>Pi Weave {props.info.version}</p>
33
- <dl class="weave-settings-locations"><dt>Vault</dt><dd>{props.info.vaultRoot || "Loading…"}</dd><dt>Repository</dt><dd>{props.cwd}</dd></dl>
34
- <div class="weave-settings-actions"><button type="button" onClick={props.onRefresh}>Refresh workspace</button><a href="https://github.com/EranYonai/pi-weave#readme" target="_blank" rel="noreferrer">Documentation ↗</a></div>
34
+ <dl class="weave-settings-locations"><dt>Vault</dt><dd><code>{props.info.vaultRoot || "Loading…"}</code></dd><dt>Repository</dt><dd><code>{props.cwd}</code></dd></dl>
35
+ <div class="weave-settings-actions"><button type="button" onClick={() => { setRefreshCount((value) => value + 1); props.onRefresh(); }}><span key={refreshCount} class={refreshCount ? "weave-refresh-spin" : ""} aria-hidden="true">↻</span> Refresh workspace</button><a href="https://github.com/EranYonai/pi-weave#readme" target="_blank" rel="noreferrer">Documentation ↗</a></div>
35
36
  <details><summary tabIndex={0}>Keyboard shortcuts</summary>{keyHelp(props.shortcut.slice(0, -1)).map((group) => <div key={group.title}><h4>{group.title}</h4>{group.entries.map((entry) => <div class="weave-key-row" key={entry.combo}><kbd>{entry.combo}</kbd><span>{entry.what}</span></div>)}</div>)}</details>
36
37
  </> : null}
37
38
  {category === "Appearance" ? <>
@@ -4,7 +4,7 @@ import type { GraphPayload, NotePayload } from "../../shared/wire";
4
4
  import { activePane, activeTab, activateTab, closePane, closeTab, focusDocument, initialLayout, reorderTab, moveTab, navigateTab, openDocument, openWithPreferences, restoreWorkspace, openGraph, parseWorkspaceLayout, splitPane, tabDropSource, tabSelection } from "../../shared/workspace";
5
5
  import type { Pane, WorkspaceLayout, WorkspaceTab } from "../../shared/workspace";
6
6
  import { fetchJson } from "../api.dom";
7
- import { openNote, saveNote } from "../api";
7
+ import { createNote, fetchGraph, openNote, saveNote } from "../api";
8
8
  import { Graph } from "../graph/Graph";
9
9
  import { schemeOf, watchScheme } from "../graph/scheme";
10
10
  import { createSigmaRenderer } from "../graph/renderer.dom";
@@ -51,13 +51,18 @@ function RecentList(props: { visits: readonly string[]; graph: GraphPayload | nu
51
51
  function DocumentView(props: {
52
52
  preferences: Preferences; tab: WorkspaceTab; graph: GraphPayload | null; drafts: DraftStore; revision: number;
53
53
  onSelect: (id: string, newTab?: boolean) => void; onSave: (slug: string, body: string) => Promise<boolean>;
54
- now: number; onScroll: (value: number) => void; onSearch: () => void; onGraph: () => void;
54
+ now: number; onScroll: (value: number) => void; onSearch: () => void; onGraph: () => void; onCreate: (title: string) => Promise<boolean>;
55
55
  }) {
56
56
  const id = tabSelection(props.tab);
57
57
  const slug = id?.startsWith("note:") ? id.slice(5) : null;
58
58
  const [loaded, setLoaded] = useState<{ slug: string; note: NotePayload | null; failed: boolean; version: number } | null>(null);
59
59
  const element = useRef<HTMLDivElement | null>(null);
60
60
  const savedScroll = useRef(props.tab.scroll);
61
+ const [newNote, setNewNote] = useState(false);
62
+ const [title, setTitle] = useState("");
63
+ const [creating, setCreating] = useState(false);
64
+ const titleInput = useRef<HTMLInputElement | null>(null);
65
+ useLayoutEffect(() => { if (newNote) titleInput.current?.focus(); }, [newNote]);
61
66
  useEffect(() => {
62
67
  if (slug === null) return;
63
68
  const version = props.drafts.nextLoadVersion();
@@ -69,11 +74,22 @@ function DocumentView(props: {
69
74
  useLayoutEffect(() => {
70
75
  const note = element.current?.querySelector<HTMLElement>(".weave-note");
71
76
  if (note !== null && note !== undefined) note.scrollTop = savedScroll.current;
77
+ if (slug !== null && props.drafts.get(slug) !== null) element.current?.querySelector<HTMLTextAreaElement>(".weave-note-editor")?.focus();
72
78
  }, [id, payload?.note?.note.slug]);
73
79
  if (id === null) return <div class="weave-welcome">
74
80
  <span class="weave-welcome-mark" aria-hidden="true">✳</span><h1>Your knowledge, connected.</h1>
75
- <p>Open a note from the sidebar, find something you remember, or follow a connection.</p>
76
- <div><button type="button" onClick={props.onSearch}>Search workspace <kbd>⌘K</kbd></button><button type="button" onClick={props.onGraph}>Explore graph</button></div>
81
+ <p>Ask your agent to write, organize, and connect your notes. Browse that knowledge here, or start a note yourself.</p>
82
+ <div><button type="button" onClick={props.onSearch}>Search workspace <kbd>⌘K</kbd></button><button type="button" onClick={props.onGraph}>Explore graph</button><button type="button" onClick={() => setNewNote(true)}>New note</button></div>
83
+ {newNote ? <form class="weave-new-note" onSubmit={(event) => {
84
+ event.preventDefault();
85
+ if (creating || !title.trim()) return;
86
+ setCreating(true);
87
+ void props.onCreate(title.trim()).finally(() => setCreating(false));
88
+ }} onKeyDown={(event) => { if (event.key === "Escape") { event.stopPropagation(); if (!creating) setNewNote(false); } }}>
89
+ <label for={`new-note-${props.tab.id}`}>Note title</label>
90
+ <input id={`new-note-${props.tab.id}`} ref={titleInput} required value={title} disabled={creating} onInput={(event) => setTitle(event.currentTarget.value)} />
91
+ <div><button type="submit" disabled={creating || !title.trim()}>{creating ? "Creating…" : "Create note"}</button><button type="button" disabled={creating} onClick={() => setNewNote(false)}>Cancel</button></div>
92
+ </form> : null}
77
93
  </div>;
78
94
  const missing = props.graph !== null && !props.graph.model.nodes.some((node) => node.id === id);
79
95
  if (missing || (payload?.failed && props.drafts.isDirty(slug ?? ""))) {
@@ -186,6 +202,16 @@ export function Shell(props: ShellProps) {
186
202
  if (!result.ok) { window.alert(result.message); return false; }
187
203
  setRevision((value) => value + 1); workspace.current?.refresh(); return true;
188
204
  };
205
+ const create = async (title: string, paneId: string): Promise<boolean> => {
206
+ const result = await createNote(fetchJson, title);
207
+ if (!result.ok) { window.alert(result.message); return false; }
208
+ const graph = await fetchGraph(fetchJson);
209
+ setData((current) => ({ ...current, graph: graph.ok ? graph.data : null, graphFailed: !graph.ok }));
210
+ drafts.open(result.data.note.slug, result.data.note.body);
211
+ change(openDocument(live.current.layout, `note:${result.data.note.slug}`, { paneId }));
212
+ workspace.current?.refresh();
213
+ return true;
214
+ };
189
215
  useEffect(() => drafts.subscribe(() => setDraftRevision((value) => value + 1)), [drafts]);
190
216
  useEffect(() => {
191
217
  workspace.current = startWorkspace({ fetch: fetchJson, state: initialWorkspaceState(), setState: setData });
@@ -399,7 +425,7 @@ export function Shell(props: ShellProps) {
399
425
  <div class="weave-pane-content" role="tabpanel" tabIndex={-1} id={`panel-${group.id}`} aria-labelledby={`tab-${current.id}`} {...(current.kind === "graph" ? { ref: graphSlot } : {})}>
400
426
  {current.kind === "document" ? <DocumentView key={`${current.id}:${current.cursor}:${tabSelection(current)}`} preferences={layout.preferences} tab={current} graph={data.graph} drafts={drafts} revision={revision} now={now}
401
427
  onSelect={(id, newTab) => select(id, newTab, group.id)} onSave={save} onScroll={(value) => rememberScroll(current.id, value)}
402
- onSearch={() => setOverlay("search")} onGraph={showGraph} /> : null}
428
+ onSearch={() => setOverlay("search")} onGraph={showGraph} onCreate={(title) => create(title, group.id)} /> : null}
403
429
  </div>
404
430
  </section>;
405
431
  };
@@ -74,7 +74,9 @@ export const WORKSPACE_CSS = `
74
74
  .weave-document .weave-note-body h2{font-size:var(--weave-px-title);margin-top:28px}
75
75
  .weave-document .weave-note-editor{padding:14px max(24px,calc((100% - var(--weave-reading-width,760px))/2));min-height:220px;flex:1;font-size:var(--weave-note-size,var(--weave-px-subhead))}
76
76
  .weave-document .weave-note-tags{margin-top:12px}
77
- .weave-document .weave-note-edit,.weave-document .weave-note-save{padding:5px 10px;border-radius:var(--weave-radius)}
77
+ .weave-document .weave-note-edit,.weave-document .weave-note-save{padding:5px 10px;border-radius:var(--weave-radius);font-size:var(--weave-px-caption)}
78
+ .weave-document .weave-note-edit{color:var(--weave-dim);background:transparent;border-color:transparent}
79
+ .weave-document .weave-note-edit:hover{color:var(--weave-accent);background:var(--weave-new);border-color:transparent}
78
80
  .weave-welcome{align-self:center;margin:auto;padding:35px;max-width:560px;color:var(--weave-dim);line-height:1.7}
79
81
  .weave-welcome-mark{font-size:var(--weave-px-display);color:var(--weave-accent)}
80
82
  .weave-welcome h1{font-size:var(--weave-px-display);line-height:1.4;letter-spacing:-.025em;color:var(--weave-fg);font-weight:550}
@@ -83,6 +85,10 @@ export const WORKSPACE_CSS = `
83
85
  .weave-welcome button{border:1px solid var(--weave-line-strong);background:var(--weave-panel);padding:9px 13px;border-radius:var(--weave-radius);font-size:var(--weave-px-row)}
84
86
  .weave-welcome button:hover{border-color:var(--weave-accent)}
85
87
  .weave-welcome kbd{color:var(--weave-faint);padding-left:16px}
88
+ .weave-new-note{display:flex;flex-direction:column;gap:10px;margin-top:24px}
89
+ .weave-new-note input{font:inherit;color:var(--weave-fg);background:var(--weave-panel);border:1px solid var(--weave-line-strong);border-radius:var(--weave-radius);padding:9px 13px;min-width:0}
90
+ .weave-new-note input:focus-visible{outline:2px solid var(--weave-accent);outline-offset:2px}
91
+ .weave-new-note>div{display:flex;gap:12px}
86
92
  .weave-sidebar-context .weave-rail{flex:1;border:0;overflow:auto;padding:6px 12px}
87
93
  .weave-sidebar-context .weave-ctx-link{padding-top:7px;padding-bottom:7px}
88
94
  .weave-graph-host{position:absolute;z-index:2;display:flex;background:var(--weave-bg)}
@@ -134,7 +140,15 @@ export const WORKSPACE_CSS = `
134
140
  .weave-setting-range{display:flex;align-items:center;gap:12px;width:190px}.weave-setting-range input{width:130px;min-width:0}.weave-setting-range output{min-width:42px;text-align:right;color:var(--weave-accent);font-variant-numeric:tabular-nums}
135
141
  .weave-settings button:focus-visible,.weave-settings a:focus-visible,.weave-settings summary:focus-visible{outline:2px solid var(--weave-accent);outline-offset:2px}
136
142
  .weave-settings-actions{display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin:24px 0}.weave-settings-actions button,.weave-settings-download,.weave-settings-forces>button{display:inline-block;padding:9px 14px;border:1px solid var(--weave-line-strong);border-radius:6px;color:var(--weave-accent);background:var(--weave-raise);text-decoration:none}
137
- .weave-settings-forces>button{margin-top:20px}.weave-settings-forces>button:disabled{opacity:.5}.weave-settings-locations dd{margin:6px 0 18px;overflow-wrap:anywhere;color:var(--weave-dim);font-family:var(--weave-font-mono)}
143
+ .weave-settings-actions button{transition:transform 100ms ease,background 100ms ease}
144
+ .weave-settings-actions button:hover{background:var(--weave-new);border-color:var(--weave-accent)}
145
+ .weave-settings-actions button:active{transform:scale(.97)}
146
+ .weave-settings-actions button>span{display:inline-block}
147
+ .weave-refresh-spin{animation:weave-refresh-spin 500ms ease}
148
+ @keyframes weave-refresh-spin{to{transform:rotate(360deg)}}
149
+ .weave-settings-forces>button{margin-top:20px}.weave-settings-forces>button:disabled{opacity:.5}.weave-settings-locations dd{margin:6px 0 18px;overflow-wrap:anywhere;color:var(--weave-dim);font-family:var(--weave-mono)}
150
+ .weave-settings-locations code{display:block;padding:10px 12px;background:var(--weave-bg);border:1px solid var(--weave-line);border-radius:var(--weave-radius);font-family:var(--weave-mono);white-space:pre-wrap}
151
+ @media(prefers-reduced-motion:reduce){.weave-settings-actions button{transition:none}.weave-settings-actions button:active{transform:none}.weave-refresh-spin{animation:none}}
138
152
  .weave-settings details summary{cursor:pointer;padding:12px 0}.weave-settings .weave-key-row{gap:16px;align-items:baseline}.weave-settings .weave-key-row kbd{min-width:150px;color:var(--weave-accent)}
139
153
  @media(max-width:650px){.weave-settings{grid-template-columns:1fr;grid-template-rows:auto auto minmax(0,1fr)}.weave-settings nav{border-right:0;border-bottom:1px solid var(--weave-line);padding:8px 16px}.weave-settings nav select{width:100%;max-width:none}.weave-settings section{padding:20px}.weave-setting-row{align-items:flex-start;gap:12px}.weave-settings select{max-width:170px}.weave-setting-range{width:140px}.weave-setting-range input{width:90px}}
140
154
  `;
@@ -8,6 +8,7 @@
8
8
  * | GET | `/api/graph` | {@link GraphPayload}, ETag'd on `stamp` |
9
9
  * | GET | `/api/note/:slug` | {@link NotePayload} |
10
10
  * | POST | `/api/note/:slug` | save a note's Markdown body |
11
+ * | POST | `/api/notes` | create an empty human-authored vault note |
11
12
  * | POST | `/api/note/:slug/rename` | rename a vault note |
12
13
  * | POST | `/api/note/:slug/move` | move a vault note |
13
14
  * | DELETE | `/api/note/:slug` | delete a vault note |
@@ -56,7 +57,7 @@ import type { GraphModel as CoreGraphModel } from "../../core/graph/model";
56
57
  import { openNoteInEditor } from "../../core/openInEditor";
57
58
  import type { Note } from "../../core/types";
58
59
  import type { VaultMutationResult } from "../../core/vault";
59
- import { createFolder, deleteFolder, deleteNote, getNote, moveNote, renameFolder, renameNote, resolveHtmlPath, searchNotes, setNoteBody } from "../../core/vault";
60
+ import { addNote, createFolder, deleteFolder, deleteNote, getNote, moveNote, renameFolder, renameNote, resolveHtmlPath, searchNotes, setNoteBody } from "../../core/vault";
60
61
  import { deriveTagIndex, type TaggedNote } from "../../core/view/links";
61
62
  import type {
62
63
  GraphPayload,
@@ -394,6 +395,16 @@ async function route(
394
395
  return sendJson(res, 200, { viewId: randomUUID(), layout: await deps.workspaceState.readLatest(), info: { vaultRoot: deps.vaultRoot, version: (JSON.parse(await readFile(new URL("../../../package.json", import.meta.url), "utf8")) as { version: string }).version } }, { "cache-control": "no-store" });
395
396
  }
396
397
  if (method === "POST" && path === "/api/workspace-state") return saveWorkspaceState(deps, req, res);
398
+ if (method === "POST" && path === "/api/notes") {
399
+ const body = await readJsonBody(req);
400
+ const title = body !== null && typeof body === "object" && !Array.isArray(body) ? (body as { title?: unknown }).title : null;
401
+ if (typeof title !== "string" || !title.trim() || /[\r\n]/.test(title)) {
402
+ sendJson(res, 400, { error: "expected a non-empty, single-line title" });
403
+ return;
404
+ }
405
+ const note = await addNote(deps.vaultRoot, { title: title.trim(), body: "", source: "human" });
406
+ return sendJson(res, 201, notePayload(note), { "cache-control": "no-store" });
407
+ }
397
408
  if (path.startsWith("/api/folder/")) {
398
409
  const handled = await routeFolder(deps, method, path.slice("/api/folder/".length), req, res);
399
410
  if (handled) return;