Changeset 0.17.3 (#78)
This commit is contained in:
@@ -94,8 +94,15 @@ These invariants keep the workflow path open without building it prematurely:
|
||||
- **Channels**: Don't assume single-owner. If touching channel queries, keep
|
||||
room for `team_id`, `type` (beyond `direct`), and multi-participant access
|
||||
patterns alongside `user_id`.
|
||||
- **Notes**: Currently user-scoped. Future channel-scoped notes (attached to
|
||||
a conversation, not a personal notebook) need a `channel_id` FK option.
|
||||
- **Notes**: User-scoped with `[[wikilink]]` bi-directional linking (v0.17.3).
|
||||
The `note_links` junction table tracks directed edges between notes, with
|
||||
`target_note_id` nullable for dangling links (unresolved references). Links
|
||||
are extracted from content on save via regex, resolved by title match, and
|
||||
re-resolved when new notes are created. The graph endpoint returns all nodes,
|
||||
edges, and unresolved links for Canvas-based force-directed visualization.
|
||||
Future channel-scoped notes (attached to a conversation) need a `channel_id`
|
||||
FK option. `source_message_id` enables jump-to-source provenance from notes
|
||||
back to the originating chat message.
|
||||
- **Personas**: Already team-scoped. Don't couple to "user picks from dropdown"
|
||||
— a workflow stage references a persona programmatically.
|
||||
- **Tool ExecutionContext**: Already carries `UserID` + `ChannelID`. Will need
|
||||
@@ -153,9 +160,10 @@ server/
|
||||
│ ├── presets.go # Persona CRUD (all scopes)
|
||||
│ ├── apiconfigs.go # User provider config CRUD (BYOK)
|
||||
│ ├── teams.go # Team management
|
||||
│ ├── notes.go # Notes CRUD + search
|
||||
│ ├── notes.go # Notes CRUD + search + graph + backlinks
|
||||
│ ├── knowledge.go # Knowledge base management
|
||||
│ └── ...
|
||||
├── notelinks/ # Wikilink extraction (regex → NoteLink structs)
|
||||
├── knowledge/ # KB chunking, embedding, search
|
||||
├── providers/ # LLM provider adapters
|
||||
│ ├── provider.go # Provider interface
|
||||
@@ -257,7 +265,8 @@ src/
|
||||
│ ├── settings-handlers.js # User settings CRUD, command palette
|
||||
│ ├── tokens.js # Input token counting + context budget
|
||||
│ ├── attachments.js # File upload, paste-to-file, lightbox
|
||||
│ ├── notes.js # Notes panel CRUD
|
||||
│ ├── notes.js # Notes panel CRUD + graph + daily notes
|
||||
│ ├── note-graph.js # Canvas force-directed graph visualization
|
||||
│ ├── knowledge.js # Knowledge base UI
|
||||
│ └── __tests__/ # Node.js test suite (node --test)
|
||||
├── editor/ # CM6 source (compiled at build time)
|
||||
@@ -267,7 +276,9 @@ src/
|
||||
│ ├── index.mjs # Bundle entrypoint (window.CM)
|
||||
│ ├── chat-input.mjs # Chat input factory (markdown, code blocks)
|
||||
│ ├── code-editor.mjs # Code editor factory (syntax, keybindings)
|
||||
│ └── theme.mjs # Switchboard + chat input themes
|
||||
│ ├── note-editor.mjs # Note editor factory (markdown + wikilinks)
|
||||
│ ├── wikilink.mjs # CM6 wikilink extension (parse, decorate, autocomplete)
|
||||
│ └── theme.mjs # Switchboard + chat input + note editor themes
|
||||
└── vendor/ # Vendored libraries (local + CDN fallback)
|
||||
├── marked/ # Markdown renderer
|
||||
├── purify/ # DOMPurify (XSS protection)
|
||||
@@ -279,12 +290,14 @@ src/
|
||||
|
||||
### CM6 Integration
|
||||
|
||||
The CodeMirror 6 bundle provides two factory functions exposed on `window.CM`:
|
||||
The CodeMirror 6 bundle provides three factory functions exposed on `window.CM`:
|
||||
|
||||
**`CM.chatInput(target, opts)`** — Markdown-mode editor for the chat input area. Features: auto-growing height, Enter=send / Shift+Enter=newline, WYSIWYG fenced code block decorations (visual container with monospace font and accent border), inline code shortcut (Ctrl/Cmd+E), spell check. Always uses standard keybindings.
|
||||
|
||||
**`CM.codeEditor(target, opts)`** — Full-featured code editor for the admin extension panel (JSON manifests, JavaScript scripts). Features: line numbers, bracket matching, search/replace, fold gutter, 10 bundled language modes. Supports Vim and Emacs keybindings via user preference (reconfigurable at runtime via compartments).
|
||||
|
||||
**`CM.noteEditor(target, opts)`** — Rich markdown editor for the notes panel. Features: heading size rendering (h1–h3), blockquote styling, fenced code block decorations, `[[wikilink]]` autocomplete (triggered by `[[`), wikilink chip rendering (clickable, styled by link/transclusion type), search/replace. `onLink` callback for navigating to linked notes; `linkCompleter` async callback for autocomplete results.
|
||||
|
||||
Both factories return a clean API: `getValue()`, `setValue()`, `focus()`, `destroy()`. The `ChatInput` abstraction in `chat.js` wraps the CM6 instance with a textarea fallback — all callsites use `ChatInput.getValue()` etc., never raw DOM access.
|
||||
|
||||
**Graceful degradation**: Every integration point checks `window.CM` before using CM6. If the bundle fails to load (air-gapped without the build step, CDN issues), the app falls back to native `<textarea>` with zero breakage.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# DESIGN: Notes Rich Text Editor + Obsidian-Style Linking
|
||||
# DESIGN: Notes Rich Text Editor, Obsidian-Style Linking & Knowledge Graph
|
||||
|
||||
**Version:** v0.17.3
|
||||
**Status:** Draft
|
||||
**Scope:** Upgrade the notes editor from a plain `<textarea>` to a CM6-powered
|
||||
markdown editor with live preview, `[[wikilink]]` resolution, and a backlinks
|
||||
system.
|
||||
markdown editor with live preview, `[[wikilink]]` resolution, a backlinks
|
||||
system, Canvas-rendered force-directed graph visualization, transclusion,
|
||||
note-from-selection (chat → notes bridge), and daily notes.
|
||||
|
||||
**Depends on:** v0.17.2 (CodeMirror 6 bundle provides `CM.codeEditor()` and
|
||||
the markdown language mode).
|
||||
@@ -24,6 +25,12 @@ Our advantage over Obsidian: we already have **semantic search via embeddings**
|
||||
(v0.14.0 KB infrastructure), so link autocomplete can be smarter than
|
||||
filename matching.
|
||||
|
||||
Beyond linking, the notes system sits adjacent to the chat system but has no
|
||||
bridge between them. Users accumulate valuable AI responses in conversations
|
||||
that they then manually copy into notes. Note-from-selection closes that gap.
|
||||
A visual graph makes the link topology navigable and discoverable, turning
|
||||
notes from a flat list into a connected knowledge base.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
@@ -50,6 +57,8 @@ CM.noteEditor(target, {
|
||||
highlighted — the document is still markdown but *looks* formatted
|
||||
- `[[` triggers autocomplete dropdown of existing notes (fuzzy search)
|
||||
- `[[Note Title]]` tokens rendered as clickable chips (CM6 `Decoration.widget`)
|
||||
- `![[Note Title]]` recognized as transclusion markers (decoration differs from
|
||||
regular links — see Transclusion section)
|
||||
- Line numbers off (it's a note, not code)
|
||||
- Spell check enabled (`EditorView.contentAttributes: { spellcheck: 'true' }`)
|
||||
- Vim/Emacs keybindings respected (user preference from v0.17.2)
|
||||
@@ -61,6 +70,7 @@ Standard double-bracket syntax with optional display text:
|
||||
```
|
||||
[[Note Title]] → links to note titled "Note Title"
|
||||
[[Note Title|display text]] → shows "display text", links to "Note Title"
|
||||
![[Note Title]] → embeds target note content inline (transclusion)
|
||||
```
|
||||
|
||||
Resolution order:
|
||||
@@ -105,6 +115,7 @@ CREATE TABLE IF NOT EXISTS note_links (
|
||||
target_note_id UUID REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_title TEXT NOT NULL, -- raw [[title]] text, for unresolved links
|
||||
display_text TEXT, -- optional |alias
|
||||
is_transclusion BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
PRIMARY KEY (source_note_id, target_title)
|
||||
);
|
||||
@@ -118,6 +129,7 @@ CREATE TABLE IF NOT EXISTS note_links (
|
||||
target_note_id TEXT REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_title TEXT NOT NULL,
|
||||
display_text TEXT,
|
||||
is_transclusion INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (source_note_id, target_title)
|
||||
);
|
||||
@@ -135,12 +147,15 @@ CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(target_note_id)
|
||||
the same title once (last one wins if duplicated).
|
||||
- `target_title` is always stored for human readability and re-resolution
|
||||
after renames.
|
||||
- `is_transclusion` distinguishes `![[embed]]` from `[[link]]` — useful for
|
||||
graph rendering (different edge style) and for knowing which links trigger
|
||||
content embedding in read mode.
|
||||
|
||||
### Link Extraction (on save)
|
||||
|
||||
When a note is saved, the backend:
|
||||
|
||||
1. Parses content for `[[...]]` patterns (simple regex: `\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`)
|
||||
1. Parses content for `[[...]]` and `![[...]]` patterns
|
||||
2. Resolves each title to a note ID: `SELECT id FROM notes WHERE LOWER(title) = LOWER($1) AND user_id = $2`
|
||||
3. Deletes existing links for this source note: `DELETE FROM note_links WHERE source_note_id = $1`
|
||||
4. Inserts fresh links with resolved (or NULL) target IDs
|
||||
@@ -173,6 +188,33 @@ AND source_note_id IN (SELECT id FROM notes WHERE user_id = $3);
|
||||
This runs once on note creation — cheap and ensures links "light up"
|
||||
retroactively.
|
||||
|
||||
### Graph Data Query
|
||||
|
||||
The graph endpoint returns all notes and resolved links for a user in a
|
||||
single payload. This is the full graph — filtering/clustering happens
|
||||
client-side.
|
||||
|
||||
```sql
|
||||
-- Nodes: all user's notes (lightweight — no content)
|
||||
SELECT id, title, folder_path, tags, updated_at
|
||||
FROM notes
|
||||
WHERE user_id = $1
|
||||
ORDER BY updated_at DESC;
|
||||
|
||||
-- Edges: all resolved links
|
||||
SELECT nl.source_note_id, nl.target_note_id, nl.target_title,
|
||||
nl.display_text, nl.is_transclusion
|
||||
FROM note_links nl
|
||||
JOIN notes n ON n.id = nl.source_note_id
|
||||
WHERE n.user_id = $1
|
||||
AND nl.target_note_id IS NOT NULL;
|
||||
```
|
||||
|
||||
For users with large note collections (1000+), the graph endpoint supports
|
||||
pagination via `limit`/`offset` on nodes, though the initial implementation
|
||||
fetches all. The payload is small — ~200 bytes per node, ~100 bytes per edge,
|
||||
so 1000 notes with 2000 links is ~400KB uncompressed.
|
||||
|
||||
---
|
||||
|
||||
## API Changes
|
||||
@@ -183,7 +225,9 @@ retroactively.
|
||||
No API signature change; link extraction is a server-side side effect.
|
||||
|
||||
**`POST /api/v1/notes`** — after creating a note, extract links AND resolve
|
||||
dangling links from other notes that reference this title.
|
||||
dangling links from other notes that reference this title. Accepts optional
|
||||
`source_channel_id` and `source_message_id` for provenance tracking (used by
|
||||
note-from-selection).
|
||||
|
||||
### New Endpoints
|
||||
|
||||
@@ -223,6 +267,43 @@ Uses `ILIKE` on Postgres, `LIKE` on SQLite (case-insensitive via COLLATE).
|
||||
The semantic search endpoint (`GET /api/v1/notes/search`) remains available
|
||||
for deeper searches but is overkill for autocomplete.
|
||||
|
||||
**`GET /api/v1/notes/graph`**
|
||||
|
||||
Returns the full graph topology for the current user.
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "Project Architecture",
|
||||
"folder_path": "/work",
|
||||
"tags": ["architecture", "design"],
|
||||
"updated_at": "2026-02-28T10:00:00Z",
|
||||
"link_count": 5
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"source": "uuid-1",
|
||||
"target": "uuid-2",
|
||||
"title": "Project Architecture",
|
||||
"is_transclusion": false
|
||||
}
|
||||
],
|
||||
"unresolved": [
|
||||
{
|
||||
"source": "uuid-1",
|
||||
"title": "Nonexistent Note"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`link_count` is `inbound + outbound` — used for node sizing in the graph.
|
||||
`unresolved` is included so the graph can optionally show dangling references
|
||||
as ghost nodes.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Integration
|
||||
@@ -310,6 +391,414 @@ The existing note list view shows raw markdown. After this release,
|
||||
clickable links. A small marked.js extension or post-render pass
|
||||
converts `[[...]]` to `<a>` tags with `onclick` handlers.
|
||||
|
||||
### Transclusion in Read Mode
|
||||
|
||||
`![[Note Title]]` in rendered markdown embeds the target note's content
|
||||
inline. Implementation is a post-render pass in `_showNoteReadMode()` and
|
||||
`_showNotePreview()`:
|
||||
|
||||
```javascript
|
||||
async function _renderTransclusions(containerEl) {
|
||||
const markers = containerEl.querySelectorAll('.wikilink-transclusion');
|
||||
for (const marker of markers) {
|
||||
const title = marker.dataset.title;
|
||||
try {
|
||||
const note = await _findNoteByTitle(title);
|
||||
if (!note) {
|
||||
marker.innerHTML = `<div class="transclusion-missing">
|
||||
Note not found: ${esc(title)}</div>`;
|
||||
continue;
|
||||
}
|
||||
const content = (await API.getNote(note.id)).content || '';
|
||||
marker.innerHTML = `
|
||||
<div class="transclusion-embed" data-note-id="${note.id}">
|
||||
<div class="transclusion-header" onclick="openNoteEditor('${note.id}')">
|
||||
<span class="transclusion-icon">↗</span>
|
||||
<span class="transclusion-title">${esc(note.title)}</span>
|
||||
</div>
|
||||
<div class="transclusion-content">${formatMessage(content)}</div>
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
marker.innerHTML = `<div class="transclusion-error">
|
||||
Failed to load: ${esc(title)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The marked.js extension (or post-render regex pass) converts `![[Title]]`
|
||||
to `<span class="wikilink-transclusion" data-title="Title"></span>`
|
||||
placeholder elements, then `_renderTransclusions()` fills them
|
||||
asynchronously.
|
||||
|
||||
**Recursion guard:** Transclusions do not recurse. If note A embeds note B
|
||||
and note B embeds note A, the second-level embed renders as a link chip, not
|
||||
a nested embed. The render function tracks a `Set` of already-embedded IDs.
|
||||
|
||||
**Edit mode:** In the CM6 editor, `![[Title]]` renders as a distinct chip
|
||||
(e.g., with an embed icon) but does *not* inline the content. Inline
|
||||
expansion is read-mode only — editing transcluded content requires navigating
|
||||
to the source note.
|
||||
|
||||
---
|
||||
|
||||
## Graph Visualization
|
||||
|
||||
### Approach: Canvas + Force Simulation
|
||||
|
||||
The graph uses HTML5 Canvas for rendering with a custom force-directed layout.
|
||||
No external dependencies — the force simulation is straightforward to
|
||||
implement in ~200 lines and avoids adding d3-force (~30KB) for a single
|
||||
use case.
|
||||
|
||||
The graph is **lazy-loaded**: the module (`src/js/note-graph.js`) and its
|
||||
Canvas setup only execute when the user opens the graph view.
|
||||
|
||||
### Force Simulation
|
||||
|
||||
A simple velocity Verlet integration with three forces:
|
||||
|
||||
```javascript
|
||||
// Force parameters (tunable)
|
||||
const SIM = {
|
||||
repulsion: 1000, // charge repulsion constant
|
||||
attraction: 0.005, // spring constant for linked nodes
|
||||
edgeLength: 120, // rest length of link springs
|
||||
damping: 0.9, // velocity damping per tick
|
||||
centerGravity: 0.01, // pull toward canvas center
|
||||
maxVelocity: 10, // velocity cap prevents explosions
|
||||
ticksPerFrame: 3, // sub-steps per rAF for convergence speed
|
||||
};
|
||||
```
|
||||
|
||||
**Forces applied per tick:**
|
||||
|
||||
1. **Repulsion** (all pairs): Coulomb-like `F = k / d²` between every
|
||||
node pair. For graphs under ~500 nodes, brute-force O(n²) is fine at
|
||||
60fps. If performance becomes an issue, a Barnes-Hut approximation
|
||||
(quadtree spatial index) can be added later.
|
||||
|
||||
2. **Attraction** (linked pairs): Hooke's law spring along each edge.
|
||||
`F = k * (d - restLength)` pulls linked nodes toward `edgeLength`.
|
||||
|
||||
3. **Center gravity**: Gentle pull toward canvas center prevents drift.
|
||||
`F = centerGravity * (center - position)`.
|
||||
|
||||
4. **Damping**: Multiply velocity by `damping` each tick for convergence.
|
||||
|
||||
```javascript
|
||||
function tick(nodes, edges) {
|
||||
// Reset accelerations
|
||||
for (const n of nodes) { n.ax = 0; n.ay = 0; }
|
||||
|
||||
// 1. Repulsion (all pairs)
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const dx = nodes[j].x - nodes[i].x;
|
||||
const dy = nodes[j].y - nodes[i].y;
|
||||
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
||||
const f = SIM.repulsion / (dist * dist);
|
||||
const fx = (dx / dist) * f;
|
||||
const fy = (dy / dist) * f;
|
||||
nodes[i].ax -= fx; nodes[i].ay -= fy;
|
||||
nodes[j].ax += fx; nodes[j].ay += fy;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Attraction (edges)
|
||||
for (const e of edges) {
|
||||
const s = e.source, t = e.target;
|
||||
const dx = t.x - s.x, dy = t.y - s.y;
|
||||
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
||||
const f = SIM.attraction * (dist - SIM.edgeLength);
|
||||
const fx = (dx / dist) * f;
|
||||
const fy = (dy / dist) * f;
|
||||
s.ax += fx; s.ay += fy;
|
||||
t.ax -= fx; t.ay -= fy;
|
||||
}
|
||||
|
||||
// 3. Center gravity + 4. Integration
|
||||
const cx = canvas.width / 2, cy = canvas.height / 2;
|
||||
for (const n of nodes) {
|
||||
if (n.pinned) continue; // dragged nodes don't move
|
||||
n.ax += (cx - n.x) * SIM.centerGravity;
|
||||
n.ay += (cy - n.y) * SIM.centerGravity;
|
||||
n.vx = clamp((n.vx + n.ax) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
|
||||
n.vy = clamp((n.vy + n.ay) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
|
||||
n.x += n.vx;
|
||||
n.y += n.vy;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The simulation runs in `requestAnimationFrame`. Once total kinetic energy
|
||||
drops below a threshold, the loop switches to on-demand rendering (redraw
|
||||
only on interaction) to save battery.
|
||||
|
||||
### Canvas Rendering
|
||||
|
||||
```javascript
|
||||
function render(ctx, nodes, edges, state) {
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
// Apply zoom/pan transform
|
||||
ctx.save();
|
||||
ctx.translate(state.panX, state.panY);
|
||||
ctx.scale(state.zoom, state.zoom);
|
||||
|
||||
// Edges
|
||||
for (const e of edges) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(e.source.x, e.source.y);
|
||||
ctx.lineTo(e.target.x, e.target.y);
|
||||
ctx.strokeStyle = e.is_transclusion
|
||||
? 'rgba(var(--accent-rgb), 0.6)' // dashed for embeds
|
||||
: 'rgba(150, 150, 150, 0.4)';
|
||||
if (e.is_transclusion) ctx.setLineDash([4, 4]);
|
||||
else ctx.setLineDash([]);
|
||||
ctx.lineWidth = e.highlighted ? 2 : 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Nodes
|
||||
for (const n of nodes) {
|
||||
const r = baseRadius + Math.sqrt(n.link_count) * 2; // size by connectivity
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = _folderColor(n.folder_path); // color by folder
|
||||
if (n.hovered || n.selected) {
|
||||
ctx.fillStyle = 'var(--accent)';
|
||||
}
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = n.selected ? '#fff' : 'rgba(0,0,0,0.2)';
|
||||
ctx.lineWidth = n.selected ? 2 : 1;
|
||||
ctx.stroke();
|
||||
|
||||
// Labels (only at sufficient zoom)
|
||||
if (state.zoom > 0.6) {
|
||||
ctx.fillStyle = 'var(--text-primary)';
|
||||
ctx.font = `${11 / state.zoom}px var(--font-family)`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(n.title, n.x, n.y + r + 12);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
```
|
||||
|
||||
### Interaction
|
||||
|
||||
- **Pan**: Mouse drag on background / touch drag
|
||||
- **Zoom**: Scroll wheel / pinch — clamped to `[0.2, 3.0]`
|
||||
- **Drag node**: Click-and-drag pins the node; release unpins
|
||||
- **Hover**: Highlight node + its direct edges/neighbors (dim others)
|
||||
- **Click node**: Open that note in the editor
|
||||
(`openNoteEditor(node.id)` — graph stays open in background)
|
||||
- **Right-click / long-press**: Context menu with "Open note",
|
||||
"Show backlinks", "Focus neighborhood"
|
||||
- **Tag coloring**: Tag pills in the legend toggle folder-based or
|
||||
tag-based coloring mode
|
||||
|
||||
Hit detection uses simple distance-from-center checks (cheaper than
|
||||
path-based detection and sufficient for circular nodes).
|
||||
|
||||
### Graph UI Placement
|
||||
|
||||
The graph opens as a **full side-panel view** (replacing the notes list),
|
||||
toggled by a new "Graph" button in the notes panel toolbar:
|
||||
|
||||
```html
|
||||
<div id="notesGraphView" class="notes-graph-view" style="display:none">
|
||||
<div class="notes-graph-toolbar">
|
||||
<button onclick="showNotesList()" title="Back to list">← List</button>
|
||||
<span class="notes-graph-stats">
|
||||
<span id="graphNodeCount">0</span> notes ·
|
||||
<span id="graphEdgeCount">0</span> links
|
||||
</span>
|
||||
<button onclick="_graphResetZoom()" title="Reset view">⊞</button>
|
||||
</div>
|
||||
<canvas id="noteGraphCanvas"></canvas>
|
||||
</div>
|
||||
```
|
||||
|
||||
The Canvas fills the panel and resizes with it (`ResizeObserver`). The graph
|
||||
fetches data from `GET /api/v1/notes/graph` on open and caches it for the
|
||||
session (invalidated on note save/delete).
|
||||
|
||||
### Orphan Detection
|
||||
|
||||
Nodes with zero inbound and zero outbound links are rendered as smaller, muted
|
||||
circles at the periphery. The graph toolbar includes a filter toggle:
|
||||
"Show orphans" (default on) to declutter dense graphs.
|
||||
|
||||
---
|
||||
|
||||
## Note-from-Selection (Chat → Notes Bridge)
|
||||
|
||||
### UX Flow
|
||||
|
||||
A new "Save to Note" button is added to the message action bar alongside
|
||||
the existing Copy/Edit/Regen buttons:
|
||||
|
||||
```javascript
|
||||
// In ui-core.js, message template
|
||||
const saveNoteBtn = `<button class="msg-action-btn"
|
||||
onclick="saveMessageToNote(${index})" title="Save to note">Note</button>`;
|
||||
```
|
||||
|
||||
When clicked:
|
||||
|
||||
1. **Full message**: If no text is selected within the message, the entire
|
||||
message content is used.
|
||||
2. **Selection**: If the user has text selected within the message, only the
|
||||
selected text is captured (via `window.getSelection()`).
|
||||
|
||||
A small modal/popover appears with:
|
||||
- **Title** field (pre-filled: first line of content, truncated to 60 chars)
|
||||
- **Folder** dropdown (existing folders + "new folder" option)
|
||||
- **Tags** input
|
||||
- **Append to existing note** toggle — when enabled, shows a note picker
|
||||
(reuses `searchNoteTitles` autocomplete)
|
||||
- **Create** / **Append** button
|
||||
|
||||
```javascript
|
||||
async function saveMessageToNote(msgIndex) {
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const msg = chat?.messages[msgIndex];
|
||||
if (!msg) return;
|
||||
|
||||
// Check for text selection within the message element
|
||||
const sel = window.getSelection();
|
||||
let content = msg.content;
|
||||
const msgEl = document.querySelector(
|
||||
`.message[data-msg-id="${msg.id}"] .msg-text`);
|
||||
if (sel && sel.rangeCount > 0 && msgEl?.contains(sel.anchorNode)) {
|
||||
content = sel.toString().trim() || msg.content;
|
||||
}
|
||||
|
||||
_showSaveToNoteModal({
|
||||
content,
|
||||
sourceChannelId: App.currentChatId,
|
||||
sourceMessageId: msg.id,
|
||||
defaultTitle: _extractTitle(content),
|
||||
});
|
||||
}
|
||||
|
||||
function _extractTitle(content) {
|
||||
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||
return firstLine.slice(0, 60) || 'Chat excerpt';
|
||||
}
|
||||
```
|
||||
|
||||
### Backend: Provenance Tracking
|
||||
|
||||
The `source_channel_id` field already exists on the Note model. We add
|
||||
`source_message_id` for finer provenance:
|
||||
|
||||
```sql
|
||||
-- Migration (Postgres)
|
||||
ALTER TABLE notes ADD COLUMN IF NOT EXISTS source_message_id UUID;
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_source_msg ON notes(source_message_id)
|
||||
WHERE source_message_id IS NOT NULL;
|
||||
|
||||
-- Migration (SQLite)
|
||||
ALTER TABLE notes ADD COLUMN source_message_id TEXT;
|
||||
```
|
||||
|
||||
The create note request body gains optional `source_message_id`:
|
||||
|
||||
```go
|
||||
type createNoteRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
Tags []string `json:"tags"`
|
||||
SourceChannelID string `json:"source_channel_id"`
|
||||
SourceMessageID string `json:"source_message_id"`
|
||||
}
|
||||
```
|
||||
|
||||
This enables future "jump to source" — from a note, navigate back to the
|
||||
exact chat message it was extracted from.
|
||||
|
||||
### Append Mode
|
||||
|
||||
When the user chooses "Append to existing note", the frontend calls
|
||||
the existing `PUT /api/v1/notes/:id` with `mode: "append"`:
|
||||
|
||||
```javascript
|
||||
await API.updateNote(targetNoteId, {
|
||||
content: '\n\n---\n\n' + content,
|
||||
mode: 'append',
|
||||
});
|
||||
```
|
||||
|
||||
The separator (`---`) visually distinguishes appended excerpts. The append
|
||||
mode already exists in the backend.
|
||||
|
||||
---
|
||||
|
||||
## Daily Notes
|
||||
|
||||
### UX
|
||||
|
||||
A "Today" button in the notes panel toolbar creates or opens today's daily
|
||||
note:
|
||||
|
||||
```html
|
||||
<button id="notesTodayBtn" onclick="openDailyNote()" title="Today's note">
|
||||
📅 Today
|
||||
</button>
|
||||
```
|
||||
|
||||
```javascript
|
||||
async function openDailyNote() {
|
||||
const today = new Date().toISOString().slice(0, 10); // "2026-02-28"
|
||||
const title = `Daily — ${today}`;
|
||||
const folder = '/daily';
|
||||
|
||||
// Try to find existing daily note
|
||||
const resp = await API.searchNoteTitles(title, 1);
|
||||
const existing = (resp.data || []).find(
|
||||
n => n.title.toLowerCase() === title.toLowerCase()
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await openNoteEditor(existing.id);
|
||||
} else {
|
||||
// Create with template
|
||||
const content = _dailyTemplate(today);
|
||||
const created = await API.createNote(title, content, folder, ['daily']);
|
||||
await openNoteEditor(created.id);
|
||||
}
|
||||
}
|
||||
|
||||
function _dailyTemplate(dateStr) {
|
||||
return `# ${dateStr}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
|
||||
}
|
||||
```
|
||||
|
||||
Daily notes live in the `/daily` folder by convention. The template is
|
||||
intentionally minimal — users customize by editing. A future enhancement
|
||||
could allow configurable daily note templates via settings.
|
||||
|
||||
### Linking Daily Notes
|
||||
|
||||
Daily notes are regular notes and participate fully in `[[wikilinks]]`. A
|
||||
user can link from any note to a daily note:
|
||||
`[[Daily — 2026-02-28]]` and vice versa.
|
||||
|
||||
Previous/next day navigation buttons appear in the editor toolbar when
|
||||
viewing a daily note (detected by folder = `/daily` and title pattern):
|
||||
|
||||
```javascript
|
||||
function _isDailyNote(note) {
|
||||
return note.folder_path === '/daily' &&
|
||||
/^Daily — \d{4}-\d{2}-\d{2}$/.test(note.title);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Access Control
|
||||
@@ -318,6 +807,7 @@ converts `[[...]]` to `<a>` tags with `onclick` handlers.
|
||||
- Create links to notes they own (their own notes)
|
||||
- See backlinks from notes they own
|
||||
- Autocomplete searches their own notes
|
||||
- View their own graph
|
||||
|
||||
Team-scoped notes (via `team_id` on the notes table) follow the same
|
||||
grant model as other team resources. When team notes ship (potentially
|
||||
@@ -344,12 +834,16 @@ type NoteLinkStore interface {
|
||||
|
||||
// Backlinks returns notes that link to the given note.
|
||||
Backlinks(ctx context.Context, noteID string) ([]NoteLinkResult, error)
|
||||
|
||||
// Graph returns all nodes and edges for a user's note graph.
|
||||
Graph(ctx context.Context, userID string) (*NoteGraph, error)
|
||||
}
|
||||
|
||||
type NoteLink struct {
|
||||
TargetNoteID *string // nil for unresolved
|
||||
TargetTitle string
|
||||
DisplayText string
|
||||
TargetNoteID *string // nil for unresolved
|
||||
TargetTitle string
|
||||
DisplayText string
|
||||
IsTransclusion bool
|
||||
}
|
||||
|
||||
type NoteLinkResult struct {
|
||||
@@ -359,6 +853,33 @@ type NoteLinkResult struct {
|
||||
UpdatedAt time.Time
|
||||
DisplayText string
|
||||
}
|
||||
|
||||
type NoteGraph struct {
|
||||
Nodes []NoteGraphNode
|
||||
Edges []NoteGraphEdge
|
||||
Unresolved []NoteGraphDangling
|
||||
}
|
||||
|
||||
type NoteGraphNode struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
Tags []string `json:"tags"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LinkCount int `json:"link_count"`
|
||||
}
|
||||
|
||||
type NoteGraphEdge struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Title string `json:"title"`
|
||||
IsTransclusion bool `json:"is_transclusion"`
|
||||
}
|
||||
|
||||
type NoteGraphDangling struct {
|
||||
Source string `json:"source"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
```
|
||||
|
||||
Implementations in both `store/postgres/note_link.go` and
|
||||
@@ -367,19 +888,23 @@ Implementations in both `store/postgres/note_link.go` and
|
||||
### Link Extraction Helper
|
||||
|
||||
```go
|
||||
// ExtractWikilinks parses [[Title]] and [[Title|Display]] from markdown content.
|
||||
// ExtractWikilinks parses [[Title]], [[Title|Display]], and ![[Title]] from markdown.
|
||||
func ExtractWikilinks(content string) []NoteLink {
|
||||
re := regexp.MustCompile(`\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]`)
|
||||
re := regexp.MustCompile(`(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]`)
|
||||
matches := re.FindAllStringSubmatch(content, -1)
|
||||
seen := make(map[string]bool)
|
||||
var links []NoteLink
|
||||
for _, m := range matches {
|
||||
title := strings.TrimSpace(m[1])
|
||||
if seen[strings.ToLower(title)] { continue }
|
||||
seen[strings.ToLower(title)] = true
|
||||
link := NoteLink{TargetTitle: title}
|
||||
if len(m) > 2 && m[2] != "" {
|
||||
link.DisplayText = strings.TrimSpace(m[2])
|
||||
title := strings.TrimSpace(m[2])
|
||||
key := strings.ToLower(title)
|
||||
if seen[key] { continue }
|
||||
seen[key] = true
|
||||
link := NoteLink{
|
||||
TargetTitle: title,
|
||||
IsTransclusion: m[1] == "!",
|
||||
}
|
||||
if len(m) > 3 && m[3] != "" {
|
||||
link.DisplayText = strings.TrimSpace(m[3])
|
||||
}
|
||||
links = append(links, link)
|
||||
}
|
||||
@@ -394,12 +919,16 @@ string parsing with no DB dependency.
|
||||
|
||||
## Migration
|
||||
|
||||
**Migration 002** (both Postgres and SQLite):
|
||||
**Migration file** (both Postgres and SQLite):
|
||||
|
||||
```sql
|
||||
-- 002_note_links.sql
|
||||
-- note_links table
|
||||
CREATE TABLE IF NOT EXISTS note_links ( ... );
|
||||
CREATE INDEX ...;
|
||||
|
||||
-- source_message_id on notes (for note-from-selection provenance)
|
||||
ALTER TABLE notes ADD COLUMN IF NOT EXISTS source_message_id UUID;
|
||||
-- (SQLite: ALTER TABLE notes ADD COLUMN source_message_id TEXT;)
|
||||
```
|
||||
|
||||
No backfill needed — existing notes have no `[[links]]` in their content,
|
||||
@@ -408,16 +937,43 @@ with the new editor.
|
||||
|
||||
---
|
||||
|
||||
## New Frontend Files
|
||||
|
||||
```
|
||||
src/js/
|
||||
notes.js # MODIFIED — editor swap, backlinks, daily notes,
|
||||
# save-to-note modal, graph view toggle
|
||||
note-graph.js # NEW — Canvas graph renderer, force simulation,
|
||||
# interaction handling, graph data fetching
|
||||
|
||||
src/editor/
|
||||
note-editor.mjs # NEW — CM.noteEditor() factory
|
||||
wikilink.mjs # NEW — CM6 extension (parse, decorate, autocomplete)
|
||||
index.mjs # MODIFIED — exports noteEditor
|
||||
|
||||
src/css/
|
||||
styles.css # MODIFIED — graph, transclusion, backlink, daily note styles
|
||||
```
|
||||
|
||||
`note-graph.js` is loaded as a regular `<script>` (like other modules) but
|
||||
initializes lazily — it defines functions but doesn't execute until the graph
|
||||
view is opened.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Phase 1: Backend (links infrastructure)
|
||||
- [ ] Migration 002: `note_links` table (Postgres + SQLite)
|
||||
- [ ] Migration: `note_links` table (Postgres + SQLite)
|
||||
- [ ] Migration: `source_message_id` column on notes (Postgres + SQLite)
|
||||
- [ ] `NoteLinkStore` interface + Postgres/SQLite implementations
|
||||
- [ ] `ExtractWikilinks()` helper with tests
|
||||
- [ ] Link extraction on note create/update in handlers
|
||||
- [ ] Dangling link resolution on note create
|
||||
- [ ] `GET /api/v1/notes/:id/backlinks` endpoint
|
||||
- [ ] `GET /api/v1/notes/search-titles?q=` endpoint
|
||||
- [ ] `GET /api/v1/notes/graph` endpoint
|
||||
- [ ] `source_message_id` support in create handler
|
||||
- [ ] API client additions in `api.js`
|
||||
|
||||
### Phase 2: Frontend (CM6 note editor)
|
||||
@@ -428,24 +984,78 @@ with the new editor.
|
||||
- [ ] `saveNote()` reads from CM6 `.getValue()`
|
||||
- [ ] Graceful fallback if CM6 unavailable
|
||||
- [ ] Backlinks panel UI below editor
|
||||
- [ ] `[[link]]` rendering in note list preview (marked.js extension)
|
||||
- [ ] `[[link]]` rendering in note read mode (marked.js post-render pass)
|
||||
- [ ] `![[transclusion]]` rendering in read mode with recursion guard
|
||||
|
||||
### Phase 3: Polish
|
||||
### Phase 3: Graph view
|
||||
- [ ] `src/js/note-graph.js` — force simulation engine
|
||||
- [ ] Canvas rendering (nodes, edges, labels)
|
||||
- [ ] Interaction: pan, zoom (wheel + pinch), drag nodes
|
||||
- [ ] Interaction: hover highlight, click to open note
|
||||
- [ ] Node sizing by link count, coloring by folder/tag
|
||||
- [ ] Transclusion edges rendered as dashed lines
|
||||
- [ ] Orphan detection and filter toggle
|
||||
- [ ] Unresolved links as ghost nodes (optional display)
|
||||
- [ ] Graph toolbar (back to list, stats, reset zoom)
|
||||
- [ ] `ResizeObserver` for panel resize tracking
|
||||
- [ ] Energy-based simulation pause (stop rAF when converged)
|
||||
|
||||
### Phase 4: Note-from-selection + daily notes
|
||||
- [ ] "Note" button in message action bar (`ui-core.js`)
|
||||
- [ ] Save-to-note modal (title, folder, tags, append toggle)
|
||||
- [ ] Selection detection (`window.getSelection()` within message)
|
||||
- [ ] Append-to-existing-note flow (note picker + `mode: append`)
|
||||
- [ ] "Today" button in notes toolbar
|
||||
- [ ] `openDailyNote()` — find-or-create with template
|
||||
- [ ] Previous/next day navigation for daily notes
|
||||
- [ ] `/daily` folder convention
|
||||
|
||||
### Phase 5: Polish
|
||||
- [ ] Wikilink chips styled to match app theme (accent color, hover state)
|
||||
- [ ] Unresolved links styled distinctly (red/dashed, "click to create")
|
||||
- [ ] Transclusion embeds: border, header bar, expand/collapse
|
||||
- [ ] Autocomplete dropdown styled (note title + folder path hint)
|
||||
- [ ] Keyboard navigation in autocomplete (↑/↓, Enter to select, Esc to dismiss)
|
||||
- [ ] Mobile/touch testing for link taps
|
||||
- [ ] Keyboard navigation in autocomplete (↑/↓, Enter, Esc)
|
||||
- [ ] Graph: smooth zoom transitions, node label fade at distance
|
||||
- [ ] Graph: dark/light theme support (`_folderColor` palette)
|
||||
- [ ] Mobile/touch: link taps, graph pinch-zoom, note-from-selection
|
||||
- [ ] Update ARCHITECTURE.md
|
||||
- [ ] Update ROADMAP.md (move graph/transclusion/daily out of Future)
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
**Graph rendering**: The O(n²) repulsion loop is acceptable for up to ~500
|
||||
nodes at 60fps on modern hardware. If users report performance issues with
|
||||
large collections:
|
||||
- Add Barnes-Hut quadtree spatial partitioning (~100 lines, reduces to
|
||||
O(n log n))
|
||||
- WebWorker the simulation (compute positions off-main-thread, post node
|
||||
coordinates back for rendering)
|
||||
- Neither is needed for initial release
|
||||
|
||||
**Transclusion API calls**: Each `![[embed]]` triggers a `getNote()` fetch.
|
||||
For a note with many embeds, this could cause a burst of requests. Mitigated
|
||||
by:
|
||||
- Client-side cache of fetched notes (already partially in place via
|
||||
`_currentNote`)
|
||||
- A batch endpoint could be added later (`POST /api/v1/notes/batch` with
|
||||
a list of IDs) but is premature for v0.17.3
|
||||
|
||||
**Graph data caching**: The graph payload is cached client-side for the
|
||||
session and invalidated on note save/delete. Avoids re-fetching on every
|
||||
graph open.
|
||||
|
||||
---
|
||||
|
||||
## Future (not in v0.17.3)
|
||||
|
||||
- **Graph view**: visual node graph of note connections (d3 force layout)
|
||||
- **Orphan detection**: notes with no inbound or outbound links
|
||||
- **Link suggestions**: "notes that might be related" via embedding similarity
|
||||
(reuse KB infrastructure)
|
||||
- **Transclusion**: `![[Note Title]]` embeds the target note's content inline
|
||||
- **Tag linking**: `#tag` syntax as an alternative navigation path
|
||||
- **Team note links**: cross-user linking when team/channel-scoped notes ship
|
||||
- **Configurable daily note templates**: user-defined template in settings
|
||||
- **Graph clustering**: visual grouping by folder with hull/boundary rendering
|
||||
- **Graph search**: highlight matching nodes, zoom to fit results
|
||||
- **Graph export**: SVG or PNG snapshot of current graph view
|
||||
|
||||
@@ -61,12 +61,15 @@ v0.16.0 User Groups v0.17.0 Persona-KB Binding
|
||||
┌───────┴──────────────┐
|
||||
│ │
|
||||
v0.17.1 SQLite Backend ✅ v0.17.2 CodeMirror 6 ✅
|
||||
v0.17.3 Notes Graph + Wikilinks
|
||||
(dual DB) (editor bundle, chat
|
||||
│ input, ext editor)
|
||||
└───────┬──────────────┘
|
||||
│
|
||||
v0.18.0 Memory (user + persona scopes, review pipeline)
|
||||
│
|
||||
v0.18.1 Side Panel Architecture (independent panels, dual-view)
|
||||
│
|
||||
v0.19.0 Projects / Workspaces + Notifications
|
||||
│
|
||||
v0.20.0 @mention Routing + Multi-model
|
||||
@@ -337,6 +340,57 @@ Depends on: nothing (frontend-only). Prerequisite for: extension surfaces
|
||||
|
||||
---
|
||||
|
||||
## v0.17.3 — Notes Graph + Wikilinks
|
||||
|
||||
Knowledge graph and bi-directional linking for the notes system. Transforms
|
||||
notes from flat documents into an interconnected knowledge base with
|
||||
Obsidian-style `[[wikilinks]]`, backlinks, graph visualization, and
|
||||
transclusion.
|
||||
|
||||
Depends on: CodeMirror 6 (v0.17.2), Notes CRUD (v0.13.x). See
|
||||
[DESIGN-0.17.3.md](DESIGN-0.17.3.md) for full spec.
|
||||
|
||||
**Backend: Note Links**
|
||||
- [x] `note_links` table (Postgres + SQLite migrations)
|
||||
- [x] `NoteLinkStore` interface + both implementations
|
||||
- [x] `ExtractWikilinks()` regex parser + tests
|
||||
- [x] Link extraction on note create/update
|
||||
- [x] Dangling link resolution on note create
|
||||
- [x] `notes.source_message_id` column for chat-to-note provenance
|
||||
|
||||
**API Endpoints**
|
||||
- [x] `GET /notes/:id/backlinks` — notes linking to this note
|
||||
- [x] `GET /notes/search-titles?q=` — lightweight fuzzy search for autocomplete
|
||||
- [x] `GET /notes/graph` — full graph topology (nodes + edges + unresolved)
|
||||
|
||||
**CM6 Note Editor**
|
||||
- [x] `note-editor.mjs` factory: markdown live preview (heading sizes, blockquotes, code blocks)
|
||||
- [x] `wikilink.mjs` CM6 extension: parse, decorate as clickable chips, autocomplete on `[[`
|
||||
- [x] Textarea → CM6 swap in `notes.js` with graceful fallback
|
||||
- [x] `noteEditorTheme` in `theme.mjs`
|
||||
|
||||
**Graph Visualization**
|
||||
- [x] Canvas-based force-directed graph (~200 lines, no d3 dependency)
|
||||
- [x] O(n²) Coulomb repulsion + Hooke spring + center gravity
|
||||
- [x] Pan, zoom (0.15–4.0x), drag nodes, hover highlight, click to open
|
||||
- [x] Node sizing by link_count, folder-based coloring
|
||||
- [x] Ghost nodes for unresolved `[[links]]` with toggle
|
||||
- [x] Energy-based pause (stop rAF below KE threshold)
|
||||
- [x] ResizeObserver for responsive canvas
|
||||
|
||||
**Note-from-Selection + Daily Notes**
|
||||
- [x] "Note" button in message action bar
|
||||
- [x] Selection-aware capture (`window.getSelection()` within message)
|
||||
- [x] Provenance tracking (`source_channel_id` + `source_message_id`)
|
||||
- [x] "Today" button → find-or-create daily note in `/daily/`
|
||||
|
||||
**Read Mode**
|
||||
- [x] `[[link]]` rendered as clickable wikilink chips
|
||||
- [x] `![[embed]]` transclusion with async content fetch, recursion guard
|
||||
- [x] Backlinks panel (collapsible, with count badge)
|
||||
|
||||
---
|
||||
|
||||
## v0.18.0 — Memory (User + Persona Scopes)
|
||||
|
||||
Long-term memory across conversations, with scope-aware isolation.
|
||||
@@ -398,6 +452,37 @@ Depends on: compaction (v0.15.0), knowledge bases (v0.14.0), persona-KB binding
|
||||
|
||||
---
|
||||
|
||||
## v0.18.1 — Side Panel Architecture
|
||||
|
||||
Refactor the side panel system so Notes, HTML/code previews, and future
|
||||
panels (knowledge base browser, search results) operate independently
|
||||
rather than sharing a single slot. Currently, opening Notes hides the
|
||||
preview panel and vice versa — this is confusing when reviewing AI-
|
||||
generated HTML/documents while also managing notes.
|
||||
|
||||
Depends on: Notes graph + wikilinks (v0.17.3). Prerequisite for: live
|
||||
collaboration (v0.23.0) where multiple panels need simultaneous visibility.
|
||||
|
||||
**Panel System**
|
||||
- [ ] Panel registry: named panels with independent open/close state
|
||||
- [ ] Dual-view mode: two panels side-by-side (configurable split ratio)
|
||||
- [ ] Tab-persistent state: each panel remembers scroll position, open note, etc.
|
||||
- [ ] Panel priority: when space is tight (mobile), most-recently-opened wins
|
||||
- [ ] Keyboard shortcut: Ctrl/Cmd+\ cycles panels, Ctrl/Cmd+Shift+\ toggles dual
|
||||
|
||||
**Preview Panel**
|
||||
- [ ] Dedicated preview panel (independent of Notes)
|
||||
- [ ] HTML/document preview with iframe sandbox
|
||||
- [ ] Code output preview (extension-rendered blocks)
|
||||
- [ ] Live-updating during streaming responses
|
||||
|
||||
**Mobile**
|
||||
- [ ] Swipe navigation between panels
|
||||
- [ ] Bottom sheet mode for secondary panel
|
||||
- [ ] Graceful collapse to single-panel on narrow viewports
|
||||
|
||||
---
|
||||
|
||||
## v0.19.0 — Projects / Workspaces + Notifications
|
||||
|
||||
Organizational containers that group related conversations, KBs, and
|
||||
|
||||
Reference in New Issue
Block a user