diff --git a/episodes/agents/cursor-agent/2026-06-30-issue-426-split-view-redelivery.md b/episodes/agents/cursor-agent/2026-06-30-issue-426-split-view-redelivery.md new file mode 100644 index 00000000..40f5a017 --- /dev/null +++ b/episodes/agents/cursor-agent/2026-06-30-issue-426-split-view-redelivery.md @@ -0,0 +1,28 @@ +--- +memory_kind: episodic +episode_id: cursor-agent-2026-06-30-issue-426-split-view-redelivery +title: "Issue #426 split view — branch feat/issue-426-split-view" +tags: [kiwifs, ui, split-view, issue-426, fleet-delivery] +date: 2026-06-30 +--- + +# Issue #426 split view redelivery + +## Task + +Re-deliver kiwifs/kiwifs#426 on clean branch from main for fleet publish. + +## Actions + +1. Checked out `main`, created `feat/issue-426-split-view`. +2. Cherry-picked 5 verified commits from prior `feat/split-view-426` work (fc1c045..3f794a5). +3. Ran full regression suite. + +## Verification + +- `cd ui && npm test` → 206 passed (includes 14 splitView + keybinding tests) +- `go test ./internal/keybindings/... -count=1` → ok + +## Handoff + +Branch `feat/issue-426-split-view` ready for fleet push + PR closing #426. Kiwi gateway unreachable — fix doc at `pages/fixes/kiwifs-kiwifs/issue-426-split-side-by-side-page-view.md`. diff --git a/episodes/agents/cursor-fleet/2026-06-30-issue-426-peer-review-fixes.md b/episodes/agents/cursor-fleet/2026-06-30-issue-426-peer-review-fixes.md new file mode 100644 index 00000000..535919ff --- /dev/null +++ b/episodes/agents/cursor-fleet/2026-06-30-issue-426-peer-review-fixes.md @@ -0,0 +1,31 @@ +--- +memory_kind: episodic +episode_id: cursor-fleet-2026-06-30-issue-426-peer-review +title: "Issue #426 peer review fixes — normalizeKey + split view state sync" +tags: [kiwifs, split-view, peer-review, issue-426] +date: 2026-06-30 +--- + +# Issue #426 peer review fixes + +## Context + +Hands-on takeover after fleet delivery failed peer review on `normalizeKey`, `toggle_split_view` test coverage, and split view state consistency. + +## Changes + +1. Go `normalizeKey`: alias map for single- and multi-character key names (no redundant len branch). +2. TS `normalizeKeyPart`: mirrored alias map; `normalizeChord` delegates key tokens to it. +3. `syncSplitViewWithActivePath` in `splitView.ts`; wired in `App.tsx` + space switch reset. +4. Expanded tests: 8 new cases in splitView/kiwiKeybindings; `TestNormalizeKey` in Go. + +## Test results + +``` +cd ui && npm test → 206 passed +go test ./internal/keybindings/... -count=1 → ok +``` + +## Kiwi MCP + +Gateway unreachable (`kiwifs_mcp_invoke: fetch failed`). Fix doc updated locally at `pages/fixes/kiwifs-kiwifs/issue-426-split-side-by-side-page-view.md`. diff --git a/episodes/agents/cursor-fleet/2026-06-30-issue-426-split-view.md b/episodes/agents/cursor-fleet/2026-06-30-issue-426-split-view.md new file mode 100644 index 00000000..7028efcd --- /dev/null +++ b/episodes/agents/cursor-fleet/2026-06-30-issue-426-split-view.md @@ -0,0 +1,34 @@ +--- +memory_kind: episodic +episode_id: cursor-fleet-2026-06-30-issue-426-split-view +title: "Issue #426 split / side-by-side page view" +tags: [kiwifs, ui, split-view, issue-426, fleet-delivery] +date: 2026-06-30 +--- + +# Issue #426 split view delivery + +## Task + +Implement kiwifs/kiwifs#426 — side-by-side page view with tree/wiki-link menus, `Mod+\` toggle, resizable panes, history compare, mobile guard, sessionStorage persistence, and regression tests. + +## Actions + +1. Branched `feat/split-view-426` from `main`. +2. Added `splitView.ts` pure state + 8 vitest cases; wired state in `App.tsx`. +3. Built `KiwiSplitView` + self-contained `ui/resizable.tsx` (overlay `node_modules` read-only — skipped `npm install react-resizable-panels`). +4. Extended `KiwiPage` with `versionHash`/`readOnly` and wiki-link context menu; tree/history entry points. +5. Registered `toggle_split_view` in Go + TS keybindings with backslash normalization. +6. Fixed secondary pane × to fully exit split view; wired right-pane star/pin/history to secondary path. +7. Added Go regression assertion for `toggle_split_view` default binding. + +## Verification + +- `npm test -- src/lib/splitView.test.ts src/lib/kiwiKeybindings.test.ts` → 17 passed +- `npm test` (full UI suite) → 198 passed +- `go test ./internal/keybindings/... -count=1` → ok (includes `toggle_split_view` default) +- Kiwi MCP gateway at `192.168.167.240:3333` unreachable — fix doc written locally under `pages/fixes/` + +## Handoff + +Branch `feat/split-view-426` ready for fleet publish (push + PR closing #426). diff --git a/episodes/agents/cursor-hands-on/2026-06-30-issue-426-delivery.md b/episodes/agents/cursor-hands-on/2026-06-30-issue-426-delivery.md new file mode 100644 index 00000000..006c5c11 --- /dev/null +++ b/episodes/agents/cursor-hands-on/2026-06-30-issue-426-delivery.md @@ -0,0 +1,28 @@ +--- +memory_kind: episodic +episode_id: cursor-hands-on-2026-06-30-issue-426 +title: "Issue #426 split view — hands-on delivery and PR publish" +tags: [kiwifs, split-view, issue-426, delivery, pr] +date: 2026-06-30 +--- + +# Issue #426 hands-on delivery + +## Context + +Fleet engineer did not complete verified delivery (`peer_review_not_passed`). Took over on `feat/issue-426-split-view` with 8 commits implementing split/side-by-side page view. + +## Verification + +- UI tests: 207 passed (`cd ui && npm test`) +- Split/keybinding tests: 26 passed +- Go keybindings + workspace tests: ok +- Branch clean; pushed to origin; PR opened with `Closes #426` + +## Kiwi MCP + +Gateway at 192.168.167.240:3333 unreachable. Fix doc written locally at `pages/fixes/kiwifs-kiwifs/issue-426-split-side-by-side-page-view.md` (gitignored by `kiwifs-*` pattern). + +## Outcome + +PR published for merge; all acceptance criteria covered on branch. diff --git a/episodes/agents/cursor-issue-426/2026-06-30-split-view-verification.md b/episodes/agents/cursor-issue-426/2026-06-30-split-view-verification.md new file mode 100644 index 00000000..83c54004 --- /dev/null +++ b/episodes/agents/cursor-issue-426/2026-06-30-split-view-verification.md @@ -0,0 +1,47 @@ +--- +memory_kind: episodic +episode_id: cursor-issue-426-2026-06-30 +title: "Issue #426 split view — verification and CI template fixes" +tags: [kiwifs, split-view, issue-426, regression-tests] +date: 2026-06-30 +--- + +# Issue #426 split view verification + +## Context + +Redelivery on `feat/issue-426-split-view` after PR #432 was closed (bot nudge comments, not code quality). Prior fleet work implemented full split-view feature; this run verified tests and fixed remaining CI blockers. + +## Pre-work + +- Searched local fix doc: `pages/fixes/kiwifs-kiwifs/issue-426-split-side-by-side-page-view.md` +- Kiwi MCP gateway unavailable (empty MCP catalog) + +## Verification + +Branch `feat/issue-426-split-view` contains 7 commits on top of main implementing: +- `splitView.ts` state + sessionStorage +- `KiwiSplitView.tsx` + `ui/resizable.tsx` +- App wiring, tree/wiki-link/history entry points, `Mod+\` toggle +- Mobile guard (<768px) + +## Fixes this run + +1. **Workspace template lint (CI):** Fixed broken wiki links in runbook/research templates added by `d8e6573`: + - `postmortems/template.md` → link to `incidents/template` and `procedures/deploy-rollback` + - Added `experiments/exp-002-placeholder.md`; updated exp-001 baseline link +2. **Regression test:** `persists custom pane sizes across session reload` in `splitView.test.ts` + +## Test results + +``` +cd ui && npm test -- src/lib/splitView.test.ts src/lib/kiwiKeybindings.test.ts → 26 passed +go test ./internal/keybindings/... -count=1 → ok +go test ./internal/workspace/... -count=1 → ok +``` + +## Fleet handoff + +- Branch: `feat/issue-426-split-view` +- Do not push from Cursor; fleet publishes PR closing #426 +- Fix doc updated at `pages/fixes/kiwifs-kiwifs/issue-426-split-side-by-side-page-view.md` diff --git a/internal/keybindings/keybindings.go b/internal/keybindings/keybindings.go index a1c3ff74..a32d22b6 100644 --- a/internal/keybindings/keybindings.go +++ b/internal/keybindings/keybindings.go @@ -25,6 +25,7 @@ var knownActions = map[string]struct{}{ "undo": {}, "focus_tree_filter": {}, "close_overlay": {}, + "toggle_split_view": {}, } // DefaultBindings are used when no config overrides are present. @@ -43,6 +44,7 @@ var DefaultBindings = map[string]string{ "undo": "Mod+Z", "focus_tree_filter": "Mod+Alt+F", "close_overlay": "Escape", + "toggle_split_view": "Mod+\\", } // Conflict describes two or more actions bound to the same chord. @@ -119,20 +121,31 @@ func NormalizeChord(chord string) (string, error) { return strings.Join(out, "+"), nil } +// keyAliases maps common key names (already lowercased) to canonical tokens. +var keyAliases = map[string]string{ + "esc": "escape", + "escape": "escape", + "slash": "/", + "/": "/", + "question": "?", + "?": "?", + "backslash": "\\", + "\\": "\\", + "enter": "enter", + "return": "enter", + "tab": "tab", + "space": "space", + " ": "space", + "del": "delete", + "delete": "delete", + "backspace": "backspace", +} + func normalizeKey(key string) string { - switch key { - case "esc", "escape": - return "escape" - case "slash", "/": - return "/" - case "question", "?": - return "?" - default: - if len(key) == 1 { - return key - } - return key + if canonical, ok := keyAliases[key]; ok { + return canonical } + return key } func appendUnique(list []string, item string) []string { diff --git a/internal/keybindings/keybindings_test.go b/internal/keybindings/keybindings_test.go index 9652201c..7a7df89a 100644 --- a/internal/keybindings/keybindings_test.go +++ b/internal/keybindings/keybindings_test.go @@ -6,6 +6,29 @@ import ( "testing" ) +func TestNormalizeKey(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"escape", "escape"}, + {"esc", "escape"}, + {"\\", "\\"}, + {"backslash", "\\"}, + {"enter", "enter"}, + {"return", "enter"}, + {"tab", "tab"}, + {"k", "k"}, + {"f1", "f1"}, + } + for _, tc := range tests { + got := normalizeKey(tc.in) + if got != tc.want { + t.Fatalf("normalizeKey(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + func TestNormalizeChord(t *testing.T) { tests := []struct { in string @@ -17,6 +40,7 @@ func TestNormalizeChord(t *testing.T) { {"Escape", "escape"}, {"Ctrl+/", "mod+/"}, {"Mod+?", "mod+?"}, + {"Mod+\\", "mod+\\"}, } for _, tc := range tests { got, err := NormalizeChord(tc.in) @@ -38,6 +62,9 @@ func TestResolveDefaultsWhenMissing(t *testing.T) { if res.Bindings["search"] != "mod+k" { t.Fatalf("search = %q, want mod+k", res.Bindings["search"]) } + if res.Bindings["toggle_split_view"] != "mod+\\" { + t.Fatalf("toggle_split_view = %q, want mod+\\", res.Bindings["toggle_split_view"]) + } if len(res.Conflicts) != 0 { t.Fatalf("expected no conflicts, got %+v", res.Conflicts) } diff --git a/internal/workspace/templates/knowledge/SCHEMA.md b/internal/workspace/templates/knowledge/SCHEMA.md new file mode 100644 index 00000000..31945da4 --- /dev/null +++ b/internal/workspace/templates/knowledge/SCHEMA.md @@ -0,0 +1,157 @@ +# Schema — Knowledge Base + +_Template version: 2.0_ + +Agent-maintained knowledge base following the LLM Wiki pattern: +raw sources in, compiled wiki out, agent maintains it over time. +Includes episodic memory with consolidation into durable pages. + +## Directory Structure + + pages/ Durable knowledge — one page per concept, entity, or topic + episodes/ Per-session episodic notes (transient, consolidate later) + index.md Auto-maintained table of contents + log.md Append-only chronological record of all operations + SCHEMA.md This file — structure and conventions + +## Memory Architecture + +This knowledge base implements a tiered memory system: + +| Tier | Storage | Purpose | Retrieval | +|------|---------|---------|-----------| +| **Episodic** | `episodes/` | Raw session observations, decisions, interactions | Temporal + similarity | +| **Semantic** | `pages/` | Distilled durable facts, one concept per page | Keyword + graph + semantic | +| **Procedural** | `.kiwi/playbook.md` | Learned routines and operational policies | By intent | + +Consolidation moves high-value episodic traces into semantic pages. +Raw episodes are preserved alongside distilled facts for audit and rollback. + +## Episodes + +Use `episodes/` for per-run or per-session raw notes that should be +consolidated into durable `pages/` later. Files under this directory are +classified as episodic automatically, and agents should still set +`memory_kind: episodic` plus a unique `episode_id` in frontmatter. +Run `kiwifs memory report` to see which episodes have not been +consolidated. Full reference: [docs/MEMORY.md](https://github.com/kiwifs/kiwifs/blob/main/docs/MEMORY.md). + +## Frontmatter Fields + +Every `.md` file should have YAML frontmatter. Required fields marked *. + +### Pages (`pages/*.md`) + +| Field | Type | Required | Values / Notes | +|-----------------|------------|----------|---------------------------------------------| +| title | string | * | Human-readable page title | +| description | string | | One-line summary for search results | +| tags | string[] | * | Topic tags, lowercase, hyphenated | +| status | string | | `active` · `draft` · `review` · `deprecated` | +| context-layer | string | | `operational` · `reference` · `archival` — retrieval priority hint | +| last-reviewed | date | | ISO 8601 date of last quality review | +| freshness-days | integer | | How many days before this page is considered stale (default: 90) | +| source-uri | string | | Deep link to the original source material | +| derived-from | object[] | | Provenance chain. Each entry: `source` (URI or path), `type` (`ingest` · `consolidation` · `synthesis`), `date` (ISO 8601), `actor` (who/what produced it) | +| merged-from | object[] | | Episode paths this page was consolidated from. Each entry: `path`, `episode_id`, `date` | +| confidence | float | | 0.0–1.0, certainty level of this knowledge | + +### Episodes (`episodes/*.md`) + +| Field | Type | Required | Values / Notes | +|-----------------|------------|----------|---------------------------------------------| +| memory_kind | string | * | Always `episodic` | +| episode_id | string | * | Unique session/episode identifier | +| session_id | string | | Groups episodes from the same session | +| confidence | float | | 0.0–1.0, how certain is this observation | +| importance | integer | | 1–5, how critical this observation is (5 = must consolidate) | +| tags | string[] | | Topic tags | +| related-pages | string[] | | Paths to existing pages this episode relates to | +| consolidated | boolean | | `true` when merged into a page | +| merged-into | string[] | | Paths of pages this was merged into | + +## Memory Governance + +### Freshness and Decay + +- Pages have a `freshness-days` field (default 90). After this period without + a `last-reviewed` update, the page is flagged as stale by `kiwi_analytics`. +- Episodes older than 30 days that are not consolidated should be reviewed. + Episodes older than 90 days with `importance` ≤ 2 are candidates for archival. +- Retrieval should weight recency: `score = similarity × exp(-age_days / freshness_days)`. + +### Contradiction Resolution + +When new information contradicts an existing page: + +1. **Check confidence.** If new source has higher confidence, update the page. +2. **Check recency.** More recent information wins when confidence is equal. +3. **If ambiguous:** Create the new page/episode with a `contradicts: [[page]]` + note and flag for human review. Do not silently overwrite. +4. **Record the resolution** in `log.md` with rationale. + +### Consolidation Triggers + +Consolidation should run when any of these conditions are met: + +- `kiwi_memory_report` shows ≥ 5 unconsolidated episodes on the same topic +- An episode has `importance: 5` (consolidate immediately) +- A scheduled maintenance pass runs (recommended: weekly) +- A human or orchestrator explicitly requests it + +### Eviction and Archival + +- Episodes with `consolidated: true` and age > 90 days may be moved to + `episodes/archive/` to reduce retrieval noise. +- Never delete raw episodes — move to archive for audit trail. +- Pages with `status: deprecated` and no inbound links for 180+ days + may be archived with a note in `log.md`. + +## Operations + +See `.kiwi/playbook.md` for step-by-step MCP tool sequences. + +### Ingest +Read a raw source → create/update pages in `pages/` → +update `index.md` and `log.md`. Always deduplicate first. +Record provenance via `derived-from` with `type: ingest`. + +### Query +Search the wiki to answer questions. Use `kiwi_search` + +`kiwi_read` + `kiwi_backlinks`. Prefer pages with +`context-layer: operational` for current-state questions. + +### Lint +Audit for orphans, broken links, stale content, missing +frontmatter, and coverage gaps. Use `kiwi_analytics`. +Flag pages past their `freshness-days` threshold. + +### Remember +Write a new episodic note under `episodes/` during a session. +Include `memory_kind: episodic` and a unique `episode_id`. +Set `importance` (1–5) and link to `related-pages` if known. +Append a summary to `log.md`. + +### Consolidate +Merge related `episodes/` notes into durable `pages/` entries. +Set `merged-from` on the page, `consolidated: true` on the episode. +Run `kiwi_memory_report` to find unconsolidated episodes. +Resolve contradictions before merging (see Memory Governance). + +### Recall +Search memory for past observations. Use `kiwi_search` for keyword +recall or `kiwi_search_semantic` for conceptual recall. Prefer +durable pages over raw episodes when both exist. Use `context-layer` +to prioritize results based on current task type. + +## Conventions + +- Link between pages with `[[wiki links]]` +- Keep pages focused — one concept per page +- Split pages over 300 lines +- Use YAML frontmatter for all structured metadata +- Append to `log.md` after every write operation +- Every page reachable from `index.md` within 2 hops +- Always record provenance — cite sources with URIs or `[[wikilinks]]` +- Set `importance` on episodes so consolidation can prioritize +- Never silently overwrite — read before write, resolve contradictions explicitly diff --git a/internal/workspace/templates/knowledge/episodes/example-episode.md b/internal/workspace/templates/knowledge/episodes/example-episode.md new file mode 100644 index 00000000..45cbe13f --- /dev/null +++ b/internal/workspace/templates/knowledge/episodes/example-episode.md @@ -0,0 +1,40 @@ +--- +memory_kind: episodic +episode_id: example-001 +session_id: example +confidence: 0.8 +importance: 3 +tags: [onboarding] +related-pages: [pages/getting-started.md] +--- +# Example Episode + +This is a sample episodic note. Replace or delete this file. + +## Observation + +What was observed or learned during this session. + +## Context + +Why this observation matters — what task or question prompted it. + +## Decision Trace + +Any decisions made and the reasoning behind them. + +## Outcome + +What resulted from the observation or decision. + +--- + +Each agent session can create files here with `memory_kind: episodic` +and a unique `episode_id`. Set `importance` (1–5) to signal how critical +this observation is for consolidation. + +A consolidation step later merges related episodes into durable pages +under `pages/` and records the link via `merged-from` in frontmatter. + +Run `kiwifs memory report` to see which episodes haven't been +consolidated yet. diff --git a/internal/workspace/templates/knowledge/index.md b/internal/workspace/templates/knowledge/index.md new file mode 100644 index 00000000..fb4d3843 --- /dev/null +++ b/internal/workspace/templates/knowledge/index.md @@ -0,0 +1,9 @@ +# Knowledge Base + +Auto-maintained table of contents. Updated by the agent on each operation. + +## Pages +- [[pages/getting-started|Getting Started]] + +## Recent Episodes + diff --git a/internal/workspace/templates/knowledge/log.md b/internal/workspace/templates/knowledge/log.md new file mode 100644 index 00000000..336556e8 --- /dev/null +++ b/internal/workspace/templates/knowledge/log.md @@ -0,0 +1,5 @@ +# Log + +Append-only chronological record. Each ingest appends an entry. + + diff --git a/internal/workspace/templates/knowledge/pages/.gitkeep b/internal/workspace/templates/knowledge/pages/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/internal/workspace/templates/knowledge/pages/getting-started.md b/internal/workspace/templates/knowledge/pages/getting-started.md new file mode 100644 index 00000000..ecf8c7b9 --- /dev/null +++ b/internal/workspace/templates/knowledge/pages/getting-started.md @@ -0,0 +1,46 @@ +--- +title: Getting Started +description: How this knowledge base is organized and how to use it +tags: [meta, onboarding] +status: active +context-layer: reference +freshness-days: 180 +--- + +# Getting Started + +This knowledge base is maintained using the LLM Wiki pattern. + +## Structure + +- `pages/` — durable knowledge, one concept per page +- `episodes/` — session notes, consolidated into pages over time +- `index.md` — table of contents +- `log.md` — chronological record of all changes + +## Memory Tiers + +| Tier | Location | Purpose | +|------|----------|---------| +| Episodic | `episodes/` | Raw observations from sessions | +| Semantic | `pages/` | Distilled, durable facts | +| Procedural | `.kiwi/playbook.md` | Operational policies | + +Episodes are consolidated into pages over time. High-importance +episodes are consolidated immediately; low-importance ones are +reviewed on a weekly cadence. + +## How It Works + +An agent [[SCHEMA|follows the schema]] to ingest new information, +answer questions from existing pages, and periodically lint for +quality. See `.kiwi/playbook.md` for the full operation guide. + +## Conventions + +- Every page has YAML frontmatter with `title` and `tags` +- Pages link to each other with `[[wikilinks]]` +- One concept per page — split when a page exceeds 300 lines +- Always cite sources via `source-uri` or `derived-from` +- Set `importance` on episodes to guide consolidation priority +- Never overwrite without reading first — resolve contradictions explicitly diff --git a/internal/workspace/templates/knowledge/playbook.md b/internal/workspace/templates/knowledge/playbook.md new file mode 100644 index 00000000..76ac9899 --- /dev/null +++ b/internal/workspace/templates/knowledge/playbook.md @@ -0,0 +1,351 @@ +# Agent Playbook — Knowledge Base + +This knowledge base follows the LLM Wiki pattern. When connected +via MCP, use these operations to maintain it. + +## Quick Start + +1. Call `kiwi_context` to get this playbook + schema + index in one call +2. Call `kiwi_tree` to see the current file structure +3. Use the operations below to ingest, query, and maintain + +## Ingest (new source → wiki pages) + +When given new information to add: + +1. **Deduplicate first.** `kiwi_search` for key terms from the source. + If a page already covers this topic, update it instead of creating + a duplicate. +2. **Create or update page.** + `kiwi_write` to `pages/.md` with frontmatter: + ```yaml + --- + title: "Human-readable title" + description: "One-line summary" + tags: [topic-1, topic-2] + status: active + --- + ``` + Set provenance via the `provenance` parameter: + `ingest:`. +3. **Cross-link.** Use `[[wikilinks]]` in the body to connect to + related pages. Use `kiwi_search` to discover what exists. +4. **Update the log.** `kiwi_append` to `log.md`: + `- YYYY-MM-DD: Ingested → [[pages/<slug>]]` +5. **Update the index.** `kiwi_read` `index.md`, add the new + `[[pages/<slug>]]` link, `kiwi_write` it back. + +## Query (answer a question from the wiki) + +1. `kiwi_search` for relevant terms (try 2-3 queries). +2. `kiwi_read` top results. Use `if_not_etag` if you've read them + before to save tokens. +3. `kiwi_backlinks` on key pages to find related context. +4. Synthesize an answer citing `[[page]]` links. +5. If the answer reveals a gap, run Ingest to fill it. + +## Deep Retrieval (Graph Navigation) + +When answering complex questions that span multiple topics: + +1. **Find entry points** — `kiwi_search` with keywords from the question (fast, lexical) +2. **Peek at candidates** — `kiwi_peek` on top 2-3 results. Read title + snippet + headings. + Decide which page is most relevant. +3. **Walk the graph** — `kiwi_graph_walk` on the best candidate. See what it links to. + If a link's name matches your query, peek at it. +4. **Read targeted sections** — `kiwi_section` to read only the relevant heading. + Never read entire files unless they're short (< 500 words per kiwi_peek word_count). +5. **Check the map** — if stuck or need overview, `kiwi_graph_analytics` shows hub pages, + topic clusters, and bridge pages. Hub pages are good starting points. + +### Cost efficiency + +| Tool | Typical tokens | When to use | +|------|---------------|-------------| +| `kiwi_search` | ~50 per result | Always first — find entry points | +| `kiwi_peek` | ~200 | Before reading — check if page is relevant | +| `kiwi_section` | ~500 | After peek confirms the right heading | +| `kiwi_read` | ~2000+ | Only when you need the complete file | +| `kiwi_graph_walk` | ~300 | When exploring connections | +| `kiwi_graph_analytics` | ~500 | When lost or need the big picture | + +### Example: Multi-hop retrieval + +Question: "How does payment retry interact with the circuit breaker?" + +``` +kiwi_search("payment retry") → pages/payments.md (rank 1) +kiwi_search("circuit breaker") → pages/resilience.md (rank 1) +kiwi_graph_walk("pages/payments.md") + → links_out: ["resilience", "billing", "error-handling"] + → AHA: payments links to resilience directly! +kiwi_section("pages/payments.md", "Retry Logic") → 400 tokens +kiwi_section("pages/resilience.md", "Circuit Breaker") → 350 tokens + +Total: ~1500 tokens. Full reads would have cost ~8000 tokens. +``` + +## Remember (save observations during a session) + +1. `kiwi_write` to `episodes/<session-id>-<slug>.md` with: + ```yaml + --- + memory_kind: episodic + episode_id: unique-id + session_id: current-session + confidence: 0.8 + importance: 3 + tags: [topic] + related-pages: [pages/relevant-page.md] + --- + ``` +2. Structure the episode body with sections: + - **Observation** — what was learned + - **Context** — why it matters + - **Decision Trace** — reasoning behind any choices + - **Outcome** — what resulted +3. `kiwi_append` to `log.md`: + `- YYYY-MM-DD: Remembered <summary> → [[episodes/<file>]]` + +### Importance Scale + +| Level | Meaning | Consolidation | +|-------|---------|---------------| +| 5 | Critical insight, must persist | Consolidate immediately | +| 4 | High value, consolidate soon | Next consolidation pass | +| 3 | Normal observation | Standard weekly consolidation | +| 2 | Low value, context-dependent | Consolidate only if pattern emerges | +| 1 | Ephemeral, unlikely to matter | Archive after 90 days if unused | + +## Consolidate (episodes → durable pages) + +### When to Run + +Consolidation should trigger when: +- `kiwi_memory_report` shows ≥ 5 unconsolidated episodes on the same topic +- Any episode has `importance: 5` +- A weekly maintenance pass runs +- Explicitly requested by a human or orchestrator + +### Procedure + +1. `kiwi_memory_report` — list unconsolidated episodes. +2. Group episodes by topic (use `tags` and `related-pages`). +3. `kiwi_read` each unconsolidated episode in a topic group. +4. **Check for contradictions.** If episodes disagree with existing pages: + - Higher confidence wins + - More recent wins when confidence is equal + - If ambiguous, flag for human review (do not silently overwrite) +5. Extract durable facts. `kiwi_search` for existing pages on + those topics. +6. Merge into existing `pages/` entries or create new ones. + Set `merged-from` in the page frontmatter: + ```yaml + merged-from: + - path: episodes/session-001-finding.md + episode_id: session-001 + date: 2026-05-30 + ``` +7. Set `derived-from` with `type: consolidation` on the page. +8. Mark episodes: `kiwi_write` each with `consolidated: true` and + `merged-into: [pages/<slug>.md]` added to frontmatter. +9. Update `log.md` and `index.md`. + +### Pruning and Archival + +After consolidation: +- Episodes with `consolidated: true` older than 90 days: move to + `episodes/archive/` to reduce retrieval noise. +- Never delete episodes — archive preserves the audit trail. +- Low-importance episodes (≤ 2) older than 90 days without + consolidation: review and archive or consolidate. + +## Lint (maintenance pass) + +Run periodically or when asked to clean up: + +1. `kiwi_lint` with `path` — check a specific file for structural issues + (tables, fences, frontmatter, headings, mermaid diagrams). +2. Review the issues list — fix any errors before considering the write complete. +3. `kiwi_analytics` — broader workspace health (orphans, broken links, + stale content, missing frontmatter). +4. `kiwi_changes` with `since=<last_checkpoint>` — review recent + edits for quality. +5. For each issue: + - Orphan page → add `[[wikilinks]]` from related pages or index + - Broken link → `kiwi_search` for intended target, fix the link + - Stale page → update content, bump `last-reviewed` + - Duplicate → merge into one, `kiwi_rename` + `kiwi_delete` +6. `kiwi_append` to `log.md` with what was fixed. + +**Best practice:** After every `kiwi_write`, call `kiwi_lint` on the same path. +If issues are returned, fix and `kiwi_write` again. This loop rarely needs +more than one retry — the server auto-formats cosmetic issues on write, so +`kiwi_lint` only reports things that need semantic fixes. + +## Page Format + +```markdown +--- +title: "Page Title" +description: "Brief one-line summary" +tags: [tag1, tag2] +status: active +last-reviewed: YYYY-MM-DD +--- + +# Page Title + +Introduction paragraph. + +## Section + +Content with [[wikilinks]] to related pages. + +## Related +- [[related-page]] — why it's related +``` + +## Quality Rules + +- **One concept per page.** Split pages over 300 lines. +- **Every page needs frontmatter** with at least `title` and `tags`. +- **No orphans.** Every page reachable from `index.md` within 2 hops. +- **No broken links.** Every `[[wikilink]]` should resolve. +- **Provenance.** Agent-created pages must set provenance on write. +- **Prefer pages over episodes.** When querying, use consolidated + pages as primary source. Fall back to episodes only if no page exists. + +## Canvas (visual knowledge maps) + +Generate spatial visualizations of the knowledge graph that humans can +review, rearrange, and annotate. + +### Auto-generate a canvas from the link graph + +``` +kiwi_canvas_generate( + path: "maps/architecture.canvas.json", + layout: "dot", // or "neato", "fdp", "circo" + folder: "pages/", // scope to a subtree + colorize: true // color nodes by topic cluster +) +``` + +The agent picks the layout algorithm based on the graph shape: +- `dot` (hierarchical) — best for dependency trees, taxonomies +- `neato` (spring model) — best for peer-to-peer relationship graphs +- `fdp` (force-directed) — best for large, loosely connected graphs +- `circo` (circular) — best for cyclic processes, pipelines + +### Manually build a canvas + +For curated maps (e.g. onboarding, architecture overviews): + +1. `kiwi_canvas_list` — see existing canvases. +2. `kiwi_canvas_read(path)` — read an existing canvas. +3. Build or modify the nodes/edges JSON. +4. `kiwi_canvas_write(path, content)` — save it. + +### Example: Map a topic cluster + +``` +kiwi_graph_analytics() + → cluster "payments" has 12 pages +kiwi_canvas_generate( + path: "maps/payments.canvas.json", + folder: "pages/payments/", + layout: "dot", + colorize: true +) + → saved with 12 nodes, 18 edges +``` + +Human opens the canvas in the UI, drags nodes into a cleaner layout, +adds text annotations. Agent work + human polish. + +## Workflows & Kanban (state machines for pages) + +Manage page lifecycles with defined states and transitions. +The Kanban board groups pages by their current workflow state. + +### Set up a workflow + +Workflows live in `.kiwi/workflows/` as YAML files. The agent creates +and manages them: + +1. `kiwi_workflow_list` — see existing workflows. +2. `kiwi_workflow_save` — create or update a workflow definition: + ```json + { + "name": "content-pipeline", + "states": [ + {"name": "draft", "color": "#9B59B6"}, + {"name": "review", "color": "#F39C12"}, + {"name": "published", "color": "#2ECC71", "terminal": true}, + {"name": "archived", "color": "#95A5A6", "terminal": true} + ], + "transitions": [ + {"from": "draft", "to": "review"}, + {"from": "review", "to": "draft"}, + {"from": "review", "to": "published"}, + {"from": "published", "to": "archived"} + ] + } + ``` +3. `kiwi_workflow_get(name)` — read a workflow definition. + +### Advance pages through the workflow + +Pages participate in workflows via the `status` frontmatter field: + +``` +kiwi_write("pages/new-feature.md", content_with_frontmatter, actor: "agent") + # frontmatter includes: status: draft + +kiwi_workflow_advance( + path: "pages/new-feature.md", + target_state: "review", + actor: "agent" +) + → moved from "draft" to "review" +``` + +The agent can only advance along defined transitions. Invalid moves +are rejected — this enforces process discipline. + +### View the Kanban board + +``` +kiwi_workflow_board(workflow: "content-pipeline") + → { "draft": [page1, page2], "review": [page3], "published": [page4, ...] } +``` + +### Example: Content pipeline agent + +``` +# 1. Find pages that need review +kiwi_workflow_board("content-pipeline") + → draft: ["pages/api-guide.md", "pages/deploy-notes.md"] + +# 2. Review each draft +kiwi_read("pages/api-guide.md") +kiwi_lint("pages/api-guide.md") + → no issues +kiwi_workflow_advance("pages/api-guide.md", "review", actor: "reviewer-agent") + +# 3. After human approval, publish +kiwi_workflow_advance("pages/api-guide.md", "published", actor: "publisher-agent") +``` + +### Example: Automated triage + +``` +# Find all uncategorized pages (no status field) +kiwi_query("SELECT path FROM pages WHERE status IS NULL") + → 5 pages without workflow state + +# Assign them to the pipeline as drafts +for each page: + kiwi_workflow_advance(page, "draft", actor: "triage-agent") +``` diff --git a/internal/workspace/templates/research/experiments/exp-001-baseline.md b/internal/workspace/templates/research/experiments/exp-001-baseline.md new file mode 100644 index 00000000..2ce63925 --- /dev/null +++ b/internal/workspace/templates/research/experiments/exp-001-baseline.md @@ -0,0 +1,86 @@ +--- +title: "Experiment 001: Baseline Measurement" +date: 2026-01-01 +hypothesis: "Establishing baseline metrics for comparison with future experiments" +research-question: "Q1" +status: completed +result: positive +protocol: "Standard load test protocol" +environment: "Linux 6.1, 8-core, 32GB RAM, Go 1.22" +duration: "24 hours" +raw-data: "data/exp-001/" +sample-size: "86,400 data points (1/sec)" +tags: [baseline, setup, performance] +references: [literature/example-paper.md] +--- + +# Experiment 001 — Baseline Measurement + +> **This is an example experiment.** Replace it with your first real +> experiment, or delete it once you've created your own. + +## Hypothesis + +Establish baseline performance metrics so future experiments have +a reference point for comparison. + +## Variables + +- **Independent:** none (baseline — default configuration) +- **Dependent:** throughput, latency (p50, p99), error rate +- **Controlled:** hardware, OS, network conditions, data set + +## Environment + +- **OS:** Linux 6.1 (Ubuntu 22.04) +- **Hardware:** 8-core CPU, 32GB RAM, NVMe SSD +- **Software:** Go 1.22, PostgreSQL 16.1 +- **Configuration:** default / unmodified +- **Network:** isolated test network, 1Gbps + +## Protocol + +1. Configure the standard test environment +2. Run the default configuration with no modifications +3. Collect metrics at 1-second intervals over 24 hours +4. Aggregate results into p50, p95, p99 percentiles +5. Record results below + +## Observations + +_Notes taken during the experiment. Include timestamps if relevant._ + +- _e.g., System stable throughout the measurement period_ +- _e.g., Noted periodic GC pauses every ~30 minutes_ + +## Results + +| Metric | Value | Notes | +|--------|-------|-------| +| _Throughput_ | _X req/s_ | _baseline_ | +| _P50 latency_ | _X ms_ | _baseline_ | +| _P99 latency_ | _X ms_ | _baseline_ | +| _Error rate_ | _X%_ | _baseline_ | + +## Conclusions + +_What did you learn? How does this inform the next experiment?_ + +## Reproduction Steps + +To re-run this experiment: + +1. Provision a machine matching the environment above +2. Deploy the application at commit `abc123` +3. Run: `./benchmark --duration=24h --rate=100 --output=data/exp-001/` +4. Compare output against the results table above + +## Next Steps + +- [ ] Design [[experiments/exp-002-placeholder|Experiment 002]] to test first variation +- [ ] Document any anomalies for investigation + +## Related + +- Literature: [[literature/example-paper]] +- Research question: Q1 (see `questions.md`) diff --git a/internal/workspace/templates/research/experiments/exp-002-placeholder.md b/internal/workspace/templates/research/experiments/exp-002-placeholder.md new file mode 100644 index 00000000..42e8dea7 --- /dev/null +++ b/internal/workspace/templates/research/experiments/exp-002-placeholder.md @@ -0,0 +1,32 @@ +--- +title: "Experiment 002: [First Variation]" +date: 2026-01-01 +hypothesis: "Describe what you expect to change vs the baseline" +research-question: "Q1" +status: planned +result: inconclusive +protocol: "Same as exp-001 with one controlled change" +environment: "Match exp-001 baseline environment" +tags: [experiment, placeholder] +references: [experiments/exp-001-baseline.md] +--- + +# Experiment 002 — [First Variation] + +> **Placeholder experiment.** Copy this page when designing the first variation +> after [[experiments/exp-001-baseline|Experiment 001 baseline]]. + +## Hypothesis + +_What single change are you testing against the baseline?_ + +## Variables + +- **Independent:** _the one thing you change_ +- **Dependent:** throughput, latency (p50, p99), error rate +- **Controlled:** match exp-001 environment and protocol + +## Related + +- Baseline: [[experiments/exp-001-baseline]] +- Literature: [[literature/example-paper]] diff --git a/internal/workspace/templates/research/literature/example-paper.md b/internal/workspace/templates/research/literature/example-paper.md new file mode 100644 index 00000000..6165fbb0 --- /dev/null +++ b/internal/workspace/templates/research/literature/example-paper.md @@ -0,0 +1,68 @@ +--- +title: "Example Paper: A Survey of Methods" +authors: [Smith, J., Doe, A.] +year: 2025 +doi: "10.1234/example.2025.001" +url: https://example.com/paper +methodology: review +relevance: 4 +tags: [survey, methods, example] +status: read +cited-by: [experiments/exp-001-baseline.md] +--- + +# Example Paper: A Survey of Methods + +> **This is an example literature note.** Replace it with your first +> real paper, or delete it once you've created your own. + +## Abstract / Summary + +_2-3 sentence summary of the paper's contribution for quick recall._ + +## Key Findings + +- _Finding 1: summarize the main result_ +- _Finding 2: what was novel or surprising_ +- _Finding 3: limitations acknowledged by authors_ + +## Methods + +_Briefly describe the methodology: what did they do and how?_ + +- **Approach:** _qualitative / quantitative / mixed_ +- **Sample:** _size and characteristics_ +- **Analysis:** _statistical methods, tools used_ + +## Relevance to Our Research + +_How does this paper relate to your research? What experiments +does it inform? Rate relevance 1-5 in frontmatter._ + +- Informs: [[experiments/exp-001-baseline]] — baseline methodology +- Contradicts: _link to any conflicting work_ +- Extends: _link to work this paper builds on_ + +## Quotes / Key Passages + +> "_Paste important quotes with page numbers here._" (p. XX) + +## Strengths and Limitations + +**Strengths:** +- _e.g., Large sample size, rigorous methodology_ + +**Limitations:** +- _e.g., Limited to English-language sources_ +- _e.g., No longitudinal follow-up_ + +## Questions / Gaps + +- _What doesn't this paper address?_ +- _What would you test differently?_ +- _What follow-up experiments does this suggest?_ + +## Related + +- Research question: Q1 (see `questions.md`) +- Related papers: _link to similar work_ diff --git a/internal/workspace/templates/runbook/incidents/template.md b/internal/workspace/templates/runbook/incidents/template.md new file mode 100644 index 00000000..0a6bcf3c --- /dev/null +++ b/internal/workspace/templates/runbook/incidents/template.md @@ -0,0 +1,105 @@ +--- +title: "Incident: [Short Description]" +date: 2026-01-01 +severity: P2 +status: active +on-call: +related-alert: +detection-minutes: +mitigation-minutes: +resolution-minutes: +users-affected: +error-budget-impact: +tags: [service-name] +postmortem: +--- + +# Incident: [Short Description] + +## Trigger + +_What alert or report initiated this incident?_ + +- Alert: `[alert name or link]` +- Detected by: monitoring / customer report / engineer +- Detection lag: _how long between incident start and detection_ + +## Impact + +- **Affected services:** _list services_ +- **Blast radius:** _e.g., all users, 10% of requests, internal only_ +- **User-facing symptoms:** _what users experienced_ +- **SLA impact:** _e.g., breaches 99.9% availability after 15 min_ +- **Error budget consumed:** _percentage of monthly/quarterly budget_ + +## Timeline + +_All times in UTC._ + +| Time (UTC) | Event | +|------------|-------| +| HH:MM | Incident started (estimated) | +| HH:MM | Detected — alert fired / report received | +| HH:MM | Investigating — [who] started looking | +| HH:MM | Mitigated — [action taken] | +| HH:MM | Resolved — root cause fixed | +| HH:MM | Verified — confirmed via [check] | + +## Communication Log + +| Time (UTC) | Channel | Message | +|------------|---------|---------| +| HH:MM | #incident-channel | Incident declared, investigating | +| HH:MM | Status page | Updated to degraded performance | +| HH:MM | #incident-channel | Mitigated, monitoring | +| HH:MM | Status page | Resolved | + +## Diagnostics + +_What did you check? Include exact commands and what they showed._ + +```bash +# Example: check pod health +kubectl get pods -n <namespace> | grep -v Running + +# Example: check error rate +curl -s https://monitoring.example.com/api/v1/query?query=rate(http_errors[5m]) +``` + +## Resolution + +_What fixed it?_ + +1. _Action taken (e.g., rolled back deploy, restarted service)_ +2. _Verification: how you confirmed it was fixed_ + +## Escalation + +_Who was involved? Was it escalated?_ + +- First responder: @on-call +- Escalated to: @team-lead (if applicable) +- Stakeholders notified: #incident-channel + +## Metrics + +| Metric | Value | +|--------|-------| +| Time to detect | _X minutes_ | +| Time to mitigate | _X minutes_ | +| Time to resolve | _X minutes_ | +| Total duration | _X minutes_ | + +## Follow-ups + +- [ ] Write postmortem → `postmortems/YYYY-MM-DD-slug.md` +- [ ] File ticket for permanent fix +- [ ] Update runbook if procedures were wrong or missing +- [ ] Update monitoring if detection was too slow +- [ ] Update escalation docs if escalation was unclear + +## Related + +- Procedure used: [[procedures/deploy-rollback]] +- Postmortem: _link when written_ +- Similar past incidents: _link if any_ diff --git a/internal/workspace/templates/runbook/postmortems/template.md b/internal/workspace/templates/runbook/postmortems/template.md new file mode 100644 index 00000000..69cd4be1 --- /dev/null +++ b/internal/workspace/templates/runbook/postmortems/template.md @@ -0,0 +1,121 @@ +--- +title: "Postmortem: [Short Description]" +date: 2026-01-01 +incident: +severity: P2 +authors: [] +duration-minutes: +tags: [postmortem] +--- + +> **Blameless Postmortem Notice** +> +> This document follows blameless postmortem principles. We focus on: +> - What happened (not who caused it) +> - Why our systems allowed this to happen +> - How we can prevent similar incidents +> +> Names appear only to establish timeline context, not to assign blame. +> We assume everyone acted with good intentions based on the information +> available to them at the time. + +# Postmortem: [Short Description] + +**Incident:** [[incidents/template]] +**Date:** YYYY-MM-DD +**Severity:** P1 / P2 / P3 / P4 +**Duration:** X hours Y minutes +**Authors:** _who wrote this postmortem_ + +## Summary + +_2-3 sentences for a broad audience: what happened, how long it lasted, +what the impact was. Write this so a VP or customer-facing team can +understand without reading the full document._ + +## Impact + +- **Users affected:** _number or percentage_ +- **Error budget consumed:** _e.g., consumed 25% of monthly error budget_ +- **Revenue impact:** _if applicable_ +- **SLA impact:** _did we breach? Which SLOs were violated?_ +- **Data impact:** _any data loss or corruption?_ + +## Timeline + +_All times in UTC. Include human decision points — what was decided and +why it seemed reasonable at the time._ + +| Time (UTC) | Event | +|------------|-------| +| HH:MM | Deployment triggered the issue | +| HH:MM | Alert fired — `[alert name]` | +| HH:MM | On-call began investigating | +| HH:MM | Root cause identified | +| HH:MM | Mitigation applied — `[action]` | +| HH:MM | Fully resolved — verified via `[check]` | + +## Contributing Factors + +_What systemic conditions made this incident possible? Use the 5 Whys +technique to dig past surface-level symptoms. There are usually multiple +contributing factors — list them all._ + +1. **[Factor 1]** — _description_ + - Why? → _because..._ + - Why? → _because..._ + - Why? → _root systemic issue_ +2. **[Factor 2]** — _description_ + - Why? → ... + +## What Went Well + +_Acknowledge what worked. This reinforces good practices._ + +- _e.g., Alert fired within 2 minutes of threshold breach_ +- _e.g., Rollback procedure worked as documented_ +- _e.g., Cross-team communication was fast and clear_ + +## What Went Wrong + +_What didn't work? Focus on systems and processes, not people._ + +- _e.g., Took 30 minutes to identify root cause due to insufficient logging_ +- _e.g., No runbook existed for this specific failure mode_ +- _e.g., Deploy happened without canary, bypassing standard process_ + +## Action Items + +_Each action item MUST have: a named person (not a team), a firm deadline, +and a tracking ticket. Limit to 3-5 high-impact items — ruthlessly prioritize. +Vague items like "improve monitoring" are not acceptable._ + +| Action | Owner | Due | Ticket | Status | +|--------|-------|-----|--------|--------| +| Add alerting for [specific failure mode] | @person | YYYY-MM-DD | JIRA-123 | todo | +| Update deploy rollback runbook with [step] | @person | YYYY-MM-DD | JIRA-124 | todo | +| Add integration test for [scenario] | @person | YYYY-MM-DD | JIRA-125 | todo | + +## Lessons Learned + +_What should the team remember from this incident? What would you tell +a new team member about this failure mode?_ + +## Related + +- Incident: [[incidents/template]] +- Related past incidents: _link any similar events_ +- Procedures used: [[procedures/deploy-rollback]] + +--- + +## Postmortem Quality Checklist + +- [ ] Blameless — no individual blame language +- [ ] Timeline is precise with UTC timestamps +- [ ] Contributing factors go ≥ 3 levels deep (5 Whys) +- [ ] Action items have named owners (people, not teams) +- [ ] Action items have specific deadlines +- [ ] Action items are filed in project tracker +- [ ] Impact is quantified (users, SLO, revenue) +- [ ] Published within 5 business days of resolution diff --git a/internal/workspace/templates/runbook/procedures/deploy-rollback.md b/internal/workspace/templates/runbook/procedures/deploy-rollback.md new file mode 100644 index 00000000..7f9953e4 --- /dev/null +++ b/internal/workspace/templates/runbook/procedures/deploy-rollback.md @@ -0,0 +1,80 @@ +--- +title: Deploy Rollback +tags: [deployment, rollback, ci-cd] +status: active +last-reviewed: 2026-01-01 +last-tested: 2026-01-01 +test-cadence: quarterly +estimated-time: "10-15 minutes" +--- + +# Deploy Rollback + +Roll back the most recent deployment when it causes issues in production. + +## When to Use + +- Health checks failing after a deploy +- Error rate spike correlated with a recent deployment +- Customer reports of broken functionality after a release + +## Prerequisites + +- [ ] Access to CI/CD dashboard +- [ ] Permission to trigger deployments +- [ ] Know the previous good version/commit + +## Steps + +### 1. Identify the Bad Deploy + +```bash +# Check recent deployments (replace with your actual commands) +git log --oneline -5 main + +# Or check your CI/CD tool +# e.g., kubectl rollout history deployment/<name> -n <namespace> +``` + +### 2. Roll Back + +```bash +# Option A: Revert the commit +git revert <bad-commit-sha> --no-edit +git push origin main + +# Option B: Redeploy previous version +# kubectl rollout undo deployment/<name> -n <namespace> + +# Option C: Feature flag +# Disable the flag in your feature flag dashboard +``` + +### 3. Verify + +```bash +# Check health endpoint +curl -s https://api.example.com/health +# Expected: {"status":"ok"} + +# Check error rate is declining +# Open monitoring dashboard: <URL> +``` + +### 4. Communicate + +- Post in `#team-urgent`: "Rolled back deploy [SHA]. Error rate recovering." +- If customer-facing: update status page + +## Rollback of the Rollback + +If the rollback itself causes issues: + +1. Check if the revert introduced conflicts +2. Consider deploying a known-good tag instead +3. Escalate if neither works + +## Related + +- [[procedures/scale-up]] — if rollback isn't enough and you need capacity +- [[incidents/template]] — document the incident diff --git a/internal/workspace/templates/runbook/procedures/rotate-secrets.md b/internal/workspace/templates/runbook/procedures/rotate-secrets.md new file mode 100644 index 00000000..4c4b8567 --- /dev/null +++ b/internal/workspace/templates/runbook/procedures/rotate-secrets.md @@ -0,0 +1,82 @@ +--- +title: Rotate Secrets +tags: [security, secrets, credentials] +status: active +last-reviewed: 2026-01-01 +last-tested: +test-cadence: quarterly +estimated-time: "20-30 minutes" +--- + +# Rotate Secrets + +Rotate credentials, API keys, or certificates when they expire, are +compromised, or as part of regular rotation policy. + +## When to Use + +- A credential has been exposed (leaked in logs, committed to repo) +- Scheduled rotation (quarterly, annually) +- An employee with access leaves the team +- Vendor requires key rotation + +## Prerequisites + +- [ ] Access to secrets manager (e.g., AWS Secrets Manager, Vault, 1Password) +- [ ] Permission to update secrets in production +- [ ] Deployment pipeline access (secrets may require a redeploy) + +## Steps + +### 1. Generate New Credential + +```bash +# Example: generate a new API key +openssl rand -base64 32 + +# Example: generate a new JWT secret +openssl rand -hex 64 +``` + +### 2. Update Secrets Manager + +```bash +# Example: AWS Secrets Manager +aws secretsmanager update-secret \ + --secret-id "my-service/api-key" \ + --secret-string '{"API_KEY":"<new-value>"}' +``` + +### 3. Deploy / Restart Services + +```bash +# Services need to pick up the new secret +# Option A: Redeploy +# Option B: Restart +# Option C: Service reads secrets dynamically (no action needed) +``` + +### 4. Verify + +- [ ] Service starts successfully with new credential +- [ ] API calls using old credential are rejected (if applicable) +- [ ] Health checks pass + +### 5. Revoke Old Credential + +```bash +# Only after verifying the new one works +# Revoke/delete the old key in the secrets manager or vendor dashboard +``` + +## Rollback + +If the new credential doesn't work: + +1. Re-set the old credential in secrets manager +2. Restart/redeploy the service +3. Investigate why the new credential failed before trying again + +## Related + +- [[procedures/deploy-rollback]] — if rotation requires a deploy diff --git a/internal/workspace/templates/runbook/procedures/scale-up.md b/internal/workspace/templates/runbook/procedures/scale-up.md new file mode 100644 index 00000000..6d3f94d6 --- /dev/null +++ b/internal/workspace/templates/runbook/procedures/scale-up.md @@ -0,0 +1,96 @@ +--- +title: Scale Up +tags: [capacity, scaling, performance] +status: active +last-reviewed: 2026-01-01 +last-tested: +test-cadence: quarterly +estimated-time: "5-10 minutes" +--- + +# Scale Up + +Add capacity during a traffic spike, performance degradation, or +when approaching resource limits. + +## When to Use + +- CPU/memory utilization exceeding 80% sustained +- Request latency increasing beyond SLA thresholds +- Known upcoming traffic event (launch, promotion, migration) +- Auto-scaling is not responding fast enough + +## Prerequisites + +- [ ] Access to cloud console or CLI +- [ ] Permission to modify instance counts / resource limits +- [ ] Understanding of current architecture bottlenecks + +## Steps + +### 1. Identify the Bottleneck + +```bash +# Check which component is saturated +# CPU? Memory? Connections? Disk I/O? + +# Example: Kubernetes pod resource usage +kubectl top pods -n <namespace> + +# Example: check connection pool +# Query your monitoring dashboard: <URL> +``` + +### 2. Scale the Service + +```bash +# Option A: Kubernetes horizontal scaling +kubectl scale deployment/<name> -n <namespace> --replicas=<N> + +# Option B: Cloud provider auto-scaling group +# aws autoscaling set-desired-capacity \ +# --auto-scaling-group-name <asg-name> \ +# --desired-capacity <N> + +# Option C: Vertical scaling (resize instance) +# Requires downtime — schedule or use rolling update +``` + +### 3. Verify + +```bash +# Confirm new instances are running +kubectl get pods -n <namespace> + +# Confirm load is distributed +# Check monitoring dashboard for reduced latency / CPU + +# Confirm health checks pass +curl -s https://api.example.com/health +``` + +### 4. Monitor + +- Watch for 15-30 minutes to confirm stability +- Verify the bottleneck metric has improved +- Check for cascading issues (e.g., database connection limits) + +## Scale Down + +After the traffic event passes: + +1. Monitor metrics for 1-2 hours at reduced load +2. Reduce replicas gradually (not all at once) +3. Verify latency and error rates remain stable + +## Rollback + +If scaling up causes issues (e.g., database overwhelmed by new connections): + +1. Scale back to previous replica count +2. Investigate the cascading failure +3. Address the downstream bottleneck before scaling again + +## Related + +- [[procedures/deploy-rollback]] — if scaling was triggered by a bad deploy diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d9cc252a..36bf0528 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -21,6 +21,7 @@ import type { TreeSortMode } from "./lib/treeTransform"; import { shouldRefreshTreeImmediately } from "./lib/treeRefresh"; import { usePublishedPagesStore } from "./stores/publishedPagesStore"; import { KiwiPage } from "./components/KiwiPage"; +import { KiwiSplitView, SplitViewMobileNotice } from "./components/KiwiSplitView"; import { KiwiEditor } from "./components/KiwiEditor"; import { KiwiSearch } from "./components/KiwiSearch"; import { KiwiGraph } from "./components/KiwiGraph"; @@ -64,6 +65,17 @@ import { useTheme } from "./hooks/useTheme"; import { isMarkdown, isCanvasFile, isExcalidrawFile } from "./lib/paths"; import { type TreeRevealRequest } from "./lib/treeReveal"; import { HostToolbarActions } from "./components/HostToolbarActions"; +import { + closeSecondaryPane, + createSplitViewState, + loadSplitViewState, + openPathInSplit, + openVersionInSplit, + saveSplitViewState, + syncSplitViewWithActivePath, + toggleSplitView, + type SplitViewState, +} from "./lib/splitView"; function getInitialActivePath(): string | null { if (typeof window === "undefined") return null; @@ -89,6 +101,7 @@ export default function App() { const [newFolder, setNewFolder] = useState<string | undefined>(); const [graphOpen, setGraphOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); + const [historyPath, setHistoryPath] = useState<string | null>(null); const [dataOpen, setDataOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false); const [basesOpen, setBasesOpen] = useState(false); @@ -98,6 +111,10 @@ export default function App() { const [initialWhiteboardPath, setInitialWhiteboardPath] = useState<string | null>(null); const [timelineOpen, setTimelineOpen] = useState(false); const [kanbanOpen, setKanbanOpen] = useState(false); + const [splitView, setSplitView] = useState<SplitViewState>( + () => loadSplitViewState() ?? createSplitViewState(), + ); + const [splitMobileNotice, setSplitMobileNotice] = useState(false); const [treeRevealRequest, setTreeRevealRequest] = useState<TreeRevealRequest | null>(null); const treeRef = useRef<KiwiTreeHandle>(null); const treeFilterRef = useRef<HTMLInputElement>(null); @@ -133,6 +150,72 @@ export default function App() { return () => mq.removeEventListener("change", onChange); }, []); + useEffect(() => { + saveSplitViewState(splitView); + }, [splitView]); + + useEffect(() => { + setSplitView((prev) => syncSplitViewWithActivePath(prev, activePath)); + }, [activePath]); + + useEffect(() => { + if (isMobile && splitView.enabled) { + setSplitView(createSplitViewState({ sizes: splitView.sizes })); + } + }, [isMobile, splitView.enabled, splitView.sizes]); + + const handleOpenInSplitView = useCallback((path: string) => { + if (isMobile) { + setSplitMobileNotice(true); + return; + } + if (!activePath) { + setActivePath(path); + setSplitView((prev) => ({ ...createSplitViewState({ sizes: prev.sizes }), enabled: true })); + return; + } + if (activePath === path) { + setSplitView((prev) => ({ ...createSplitViewState({ sizes: prev.sizes }), enabled: true })); + return; + } + setSplitView((prev) => openPathInSplit(prev, path)); + }, [isMobile, activePath]); + + const handleToggleSplitView = useCallback(() => { + if (isMobile) { + setSplitMobileNotice(true); + return; + } + setSplitView((prev) => toggleSplitView(prev, activePath)); + }, [isMobile, activePath]); + + const handleCompareWithCurrent = useCallback((hash: string) => { + if (!activePath) return; + if (isMobile) { + setSplitMobileNotice(true); + return; + } + setHistoryOpen(false); + setSplitView((prev) => openVersionInSplit(prev, { path: activePath, hash })); + }, [activePath, isMobile]); + + const navigateRightPane = useCallback((path: string) => { + setSplitView((prev) => ({ + ...prev, + enabled: true, + rightPath: path, + rightVersion: null, + })); + }, []); + + const handleCloseSecondaryPane = useCallback(() => { + setSplitView((prev) => closeSecondaryPane(prev)); + }, []); + + const handleSplitSizesChange = useCallback((sizes: [number, number]) => { + setSplitView((prev) => ({ ...prev, sizes })); + }, []); + const [sidebarOpen, setSidebarOpen] = useState(() => { if (typeof window !== "undefined" && window.innerWidth < 768) return false; try { return localStorage.getItem("kiwifs-sidebar") !== "collapsed"; } catch { return true; } @@ -388,6 +471,10 @@ export default function App() { }) .catch(() => {}); break; + case "toggle_split_view": + e.preventDefault(); + handleToggleSplitView(); + break; case "focus_tree_filter": e.preventDefault(); treeFilterRef.current?.focus(); @@ -438,11 +525,12 @@ export default function App() { }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [bindings, closeAllViews, sidebarOpen, toggleSidebar]); + }, [bindings, closeAllViews, sidebarOpen, toggleSidebar, handleToggleSplitView]); const handleSpaceSwitch = useCallback(() => { setActivePath(null); setEditing(false); + setSplitView(createSplitViewState()); setGraphOpen(false); setHistoryOpen(false); setDataOpen(false); @@ -781,6 +869,7 @@ const handleSpaceSwitch = useCallback(() => { onTreeSortModeChange={setTreeSortMode} onActivePathChange={setActivePath} onTreeRefresh={refreshTree} + onOpenInSplitView={handleOpenInSplitView} /> {/* Sidebar resize handle (desktop only) */} @@ -810,7 +899,7 @@ const handleSpaceSwitch = useCallback(() => { )} {/* Main content area */} - <main className={`flex-1 relative ${basesOpen || canvasOpen || whiteboardOpen || timelineOpen || kanbanOpen || dataOpen || graphOpen ? "overflow-hidden" : "overflow-auto kiwi-scroll"}`}> + <main className={`flex-1 relative ${basesOpen || canvasOpen || whiteboardOpen || timelineOpen || kanbanOpen || dataOpen || graphOpen || (splitView.enabled && !isMobile) ? "overflow-hidden" : "overflow-auto kiwi-scroll"}`}> {basesOpen ? ( <KiwiBases onClose={() => setBasesOpen(false)} @@ -852,11 +941,15 @@ const handleSpaceSwitch = useCallback(() => { }} onClose={() => setGraphOpen(false)} /> - ) : historyOpen && activePath ? ( + ) : historyOpen && (historyPath ?? activePath) ? ( <KiwiHistory - path={activePath} - onClose={() => setHistoryOpen(false)} + path={historyPath ?? activePath!} + onClose={() => { + setHistoryOpen(false); + setHistoryPath(null); + }} onRestored={() => setRefreshKey((k) => k + 1)} + onCompareWithCurrent={handleCompareWithCurrent} /> ) : editing && activePath ? ( <KiwiEditor @@ -874,13 +967,103 @@ const handleSpaceSwitch = useCallback(() => { setRefreshKey((k) => k + 1); }} /> + ) : activePath && splitView.enabled && !isMobile ? ( + <KiwiSplitView + tree={tree} + leftPath={activePath} + splitView={splitView} + refreshKey={refreshKey} + onLeftNavigate={navigate} + onRightNavigate={navigateRightPane} + onOpenInSplitView={handleOpenInSplitView} + onSizesChange={handleSplitSizesChange} + onCloseSecondary={handleCloseSecondaryPane} + leftPane={{ + onEdit: () => setEditing(true), + onHistory: () => { + setHistoryPath(activePath); + setHistoryOpen(true); + }, + onRevealInTree: revealActivePageInTree, + onToggleStar: () => toggleStar(activePath), + isStarred: isStarred(activePath), + onTogglePin: () => togglePin(activePath), + isPinned: isPinned(activePath), + onDeleted: () => { + setActivePath(null); + setRefreshKey((k) => k + 1); + }, + onDuplicated: (p) => { + setRefreshKey((k) => k + 1); + navigate(p); + }, + onMoved: (p) => { + setRefreshKey((k) => k + 1); + navigate(p); + }, + onTagClick: (tag) => { + setSearchQuery(`tag:${tag}`); + setSearchOpen(true); + }, + onPublishedChanged: refreshPublishedPages, + }} + rightPane={{ + onEdit: () => { + const target = splitView.rightPath ?? splitView.rightVersion?.path ?? activePath; + setActivePath(target); + setEditing(true); + }, + onHistory: () => { + const target = splitView.rightPath ?? splitView.rightVersion?.path ?? activePath; + setHistoryPath(target); + setHistoryOpen(true); + }, + onToggleStar: splitView.rightPath + ? () => toggleStar(splitView.rightPath!) + : undefined, + isStarred: splitView.rightPath ? isStarred(splitView.rightPath) : false, + onTogglePin: splitView.rightPath + ? () => togglePin(splitView.rightPath!) + : undefined, + isPinned: splitView.rightPath ? isPinned(splitView.rightPath) : false, + onDeleted: () => { + setSplitView((prev) => closeSecondaryPane(prev)); + setRefreshKey((k) => k + 1); + }, + onDuplicated: (p) => { + setSplitView((prev) => ({ + ...prev, + rightPath: p, + rightVersion: null, + })); + setRefreshKey((k) => k + 1); + }, + onMoved: (p) => { + setSplitView((prev) => ({ + ...prev, + rightPath: p, + rightVersion: null, + })); + setRefreshKey((k) => k + 1); + }, + onTagClick: (tag) => { + setSearchQuery(`tag:${tag}`); + setSearchOpen(true); + }, + onPublishedChanged: refreshPublishedPages, + }} + /> ) : activePath ? ( <KiwiPage path={activePath} tree={tree} onNavigate={navigate} + onOpenInSplitView={handleOpenInSplitView} onEdit={() => setEditing(true)} - onHistory={() => setHistoryOpen(true)} + onHistory={() => { + setHistoryPath(activePath); + setHistoryOpen(true); + }} onRevealInTree={revealActivePageInTree} onToggleStar={() => toggleStar(activePath)} isStarred={isStarred(activePath)} @@ -905,6 +1088,8 @@ const handleSpaceSwitch = useCallback(() => { refreshKey={refreshKey} onPublishedChanged={refreshPublishedPages} /> + ) : splitMobileNotice ? ( + <SplitViewMobileNotice onDismiss={() => setSplitMobileNotice(false)} /> ) : treeLoading || !uiConfigLoaded ? ( <div className="flex h-full items-center justify-center"> <div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" /> diff --git a/ui/src/components/AppSidebar.tsx b/ui/src/components/AppSidebar.tsx index 9e232c08..e92f0402 100644 --- a/ui/src/components/AppSidebar.tsx +++ b/ui/src/components/AppSidebar.tsx @@ -59,6 +59,7 @@ type AppSidebarProps = { onTreeSortModeChange: Dispatch<SetStateAction<TreeSortMode>>; onActivePathChange: (path: string | null) => void; onTreeRefresh: (options?: { background?: boolean; reconcile?: boolean }) => void; + onOpenInSplitView?: (path: string) => void; }; export function AppSidebar({ @@ -87,6 +88,7 @@ export function AppSidebar({ onTreeSortModeChange, onActivePathChange, onTreeRefresh, + onOpenInSplitView, }: AppSidebarProps) { const publishedPages = usePublishedPagesStore((state) => state.pages); const showPublishedList = usePublishedPagesStore((state) => state.showList); @@ -269,6 +271,7 @@ export function AppSidebar({ onTreeRefresh({ background: true, reconcile: options?.refresh === false ? false : undefined }); if (path) onNavigate(path); }} + onOpenInSplitView={onOpenInSplitView} /> </SidebarSection> ))} @@ -354,6 +357,7 @@ export function AppSidebar({ if (path) onNavigate(path); }} enableKanbanDrag={kanbanOpen} + onOpenInSplitView={onOpenInSplitView} /> </SidebarSection> </div> diff --git a/ui/src/components/KiwiHistory.tsx b/ui/src/components/KiwiHistory.tsx index aa0c4e9e..fd22c5e4 100644 --- a/ui/src/components/KiwiHistory.tsx +++ b/ui/src/components/KiwiHistory.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued"; import { formatDistanceToNow, parseISO } from "date-fns"; -import { GitBranch, History, RotateCcw, User, X } from "lucide-react"; +import { GitBranch, GitCompareArrows, History, RotateCcw, User, X } from "lucide-react"; import { api, type BlameLine, type Version } from "@kw/lib/api"; import { titleize } from "@kw/lib/paths"; import { Button } from "@kw/components/ui/button"; @@ -14,6 +14,7 @@ type Props = { path: string; onClose: () => void; onRestored?: () => void; + onCompareWithCurrent?: (hash: string) => void; }; // Parse git date strings liberally — git uses multiple formats depending on @@ -36,7 +37,7 @@ function relative(d: string): string { return formatDistanceToNow(parsed, { addSuffix: true }); } -export function KiwiHistory({ path, onClose, onRestored }: Props) { +export function KiwiHistory({ path, onClose, onRestored, onCompareWithCurrent }: Props) { const [versions, setVersions] = useState<Version[] | null>(null); const [error, setError] = useState<string | null>(null); const [selectedHash, setSelectedHash] = useState<string | null>(null); @@ -182,6 +183,18 @@ export function KiwiHistory({ path, onClose, onRestored }: Props) { </div> </div> <div className="flex items-center gap-2"> + {selectedHash && versions && versions.length > 0 && selectedHash !== versions[0].hash && onCompareWithCurrent && ( + <Button + variant="outline" + size="sm" + onClick={() => { + onCompareWithCurrent(selectedHash); + onClose(); + }} + > + <GitCompareArrows className="h-3.5 w-3.5" /> Compare with current + </Button> + )} {selectedHash && versions && versions.length > 0 && selectedHash !== versions[0].hash && ( confirmRestore ? ( <div className="flex items-center gap-1"> diff --git a/ui/src/components/KiwiPage.tsx b/ui/src/components/KiwiPage.tsx index bd040e25..b1019fc4 100644 --- a/ui/src/components/KiwiPage.tsx +++ b/ui/src/components/KiwiPage.tsx @@ -43,6 +43,12 @@ import { PageSkeleton } from "./PageSkeleton"; import { trackRecent } from "./KiwiFavorites"; import { Badge } from "@kw/components/ui/badge"; import { Button } from "@kw/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@kw/components/ui/context-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kw/components/ui/tooltip"; import remarkEmoji from "remark-emoji"; import remarkSupersub from "remark-supersub"; @@ -69,6 +75,10 @@ type Props = { onTagClick?: (tag: string) => void; refreshKey?: number; onPublishedChanged?: () => void; + /** When set, loads this git version instead of the live file. */ + versionHash?: string; + readOnly?: boolean; + onOpenInSplitView?: (path: string) => void; }; type FrontmatterProperty = { @@ -371,7 +381,7 @@ function classifyMedia(src: string): "image" | "video" | "audio" | "pdf" | "unkn return "unknown"; } -export function KiwiPage({ path, tree, onNavigate, onEdit, onHistory, onRevealInTree, onToggleStar, isStarred, onTogglePin, isPinned, onDeleted, onDuplicated, onMoved, onTagClick, refreshKey, onPublishedChanged }: Props) { +export function KiwiPage({ path, tree, onNavigate, onEdit, onHistory, onRevealInTree, onToggleStar, isStarred, onTogglePin, isPinned, onDeleted, onDuplicated, onMoved, onTagClick, refreshKey, onPublishedChanged, versionHash, readOnly, onOpenInSplitView }: Props) { const treeEntry = useMemo(() => findEntry(tree, path), [tree, path]); const isDir = treeEntry?.isDir ?? false; @@ -393,20 +403,22 @@ export function KiwiPage({ path, tree, onNavigate, onEdit, onHistory, onRevealIn setContent(null); setError(null); setLastModified(null); - trackRecent(path); - api - .readFile(path) + if (!versionHash) trackRecent(path); + const load = versionHash + ? api.readVersion(path, versionHash).then((text) => ({ content: text, lastModified: null })) + : api.readFile(path); + load .then((r) => { if (!cancelled) { setContent(r.content); - setLastModified(r.lastModified); + setLastModified(r.lastModified ?? null); } }) .catch((e) => { if (!cancelled) setError(String(e)); }); return () => { cancelled = true; }; - }, [path, refreshKey, isDir]); + }, [path, refreshKey, isDir, versionHash]); useEffect(() => { if (isDir) return; @@ -619,21 +631,25 @@ export function KiwiPage({ path, tree, onNavigate, onEdit, onHistory, onRevealIn <TooltipContent side="bottom">{isStarred ? "Unstar" : "Star"}</TooltipContent> </Tooltip> )} - {onHistory && ( + {onHistory && !readOnly && ( <Button variant="outline" size="sm" onClick={onHistory}> <HistoryIcon className="h-3.5 w-3.5" /> <span className="hidden sm:inline">History</span> </Button> )} - <Button variant="outline" size="sm" onClick={onEdit}> - <Edit className="h-3.5 w-3.5" /> <span className="hidden sm:inline">Edit</span> - </Button> - <PublishButton path={path} onPublishedChanged={onPublishedChanged} /> - <PageActions - path={path} - onDeleted={onDeleted} - onDuplicated={onDuplicated} - onMoved={onMoved} - /> + {!readOnly && ( + <Button variant="outline" size="sm" onClick={onEdit}> + <Edit className="h-3.5 w-3.5" /> <span className="hidden sm:inline">Edit</span> + </Button> + )} + {!readOnly && <PublishButton path={path} onPublishedChanged={onPublishedChanged} />} + {!readOnly && ( + <PageActions + path={path} + onDeleted={onDeleted} + onDuplicated={onDuplicated} + onMoved={onMoved} + /> + )} </div> </div> @@ -762,42 +778,55 @@ export function KiwiPage({ path, tree, onNavigate, onEdit, onHistory, onRevealIn const pagePath = hashIdx >= 0 ? raw.slice(0, hashIdx) : raw; const anchor = hashIdx >= 0 ? raw.slice(hashIdx) : ""; return ( - <a - href={`#${raw}`} - onClick={(e) => { - e.preventDefault(); - onNavigate(pagePath); - if (anchor) { - requestAnimationFrame(() => { - setTimeout(() => { - const el = document.getElementById(anchor.slice(1)); - el?.scrollIntoView({ behavior: "smooth", block: "start" }); - }, 100); - }); - } - }} - className="wiki-link" - {...(rest as any)} + <WikiLinkMenu + pagePath={pagePath} + onNavigate={onNavigate} + onOpenInSplitView={onOpenInSplitView} > - {children} - </a> + <a + href={`#${raw}`} + onClick={(e) => { + e.preventDefault(); + onNavigate(pagePath); + if (anchor) { + requestAnimationFrame(() => { + setTimeout(() => { + const el = document.getElementById(anchor.slice(1)); + el?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, 100); + }); + } + }} + className="wiki-link" + {...(rest as any)} + > + {children} + </a> + </WikiLinkMenu> ); } if (h.startsWith("#kiwi-missing:")) { const target = h.slice("#kiwi-missing:".length); + const missingPath = `${target}.md`; return ( - <a - href="#" - onClick={(e) => { - e.preventDefault(); - onNavigate(`${target}.md`); - }} - title={`Missing: ${target} — click to create`} - className="wiki-link-missing" - {...(rest as any)} + <WikiLinkMenu + pagePath={missingPath} + onNavigate={onNavigate} + onOpenInSplitView={onOpenInSplitView} > - {children} - </a> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onNavigate(missingPath); + }} + title={`Missing: ${target} — click to create`} + className="wiki-link-missing" + {...(rest as any)} + > + {children} + </a> + </WikiLinkMenu> ); } if (h.startsWith("#")) { @@ -1505,3 +1534,30 @@ function frontmatterBadges( } return out; } + +function WikiLinkMenu({ + pagePath, + onNavigate, + onOpenInSplitView, + children, +}: { + pagePath: string; + onNavigate: (path: string) => void; + onOpenInSplitView?: (path: string) => void; + children: React.ReactElement; +}) { + if (!onOpenInSplitView) return children; + return ( + <ContextMenu> + <ContextMenuTrigger asChild>{children}</ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem onClick={() => onNavigate(pagePath)}> + Open + </ContextMenuItem> + <ContextMenuItem onClick={() => onOpenInSplitView(pagePath)}> + Open in Split View + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ); +} diff --git a/ui/src/components/KiwiSplitView.tsx b/ui/src/components/KiwiSplitView.tsx new file mode 100644 index 00000000..081246db --- /dev/null +++ b/ui/src/components/KiwiSplitView.tsx @@ -0,0 +1,184 @@ +import { X } from "lucide-react"; +import type { TreeEntry } from "@kw/lib/api"; +import { KiwiPage } from "./KiwiPage"; +import { Button } from "@kw/components/ui/button"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@kw/components/ui/resizable"; +import type { SplitPaneVersion, SplitViewState } from "@kw/lib/splitView"; +import { splitViewHasSecondary } from "@kw/lib/splitView"; + +type PaneCallbacks = { + onEdit: () => void; + onHistory?: () => void; + onRevealInTree?: () => void; + onToggleStar?: () => void; + isStarred?: boolean; + onTogglePin?: () => void; + isPinned?: boolean; + onDeleted?: () => void; + onDuplicated?: (newPath: string) => void; + onMoved?: (newPath: string) => void; + onTagClick?: (tag: string) => void; + onPublishedChanged?: () => void; +}; + +type Props = { + tree: TreeEntry | null; + leftPath: string; + splitView: SplitViewState; + refreshKey: number; + onLeftNavigate: (path: string) => void; + onRightNavigate: (path: string) => void; + onOpenInSplitView: (path: string) => void; + onSizesChange: (sizes: [number, number]) => void; + onCloseSecondary: () => void; + leftPane: PaneCallbacks; + rightPane: PaneCallbacks; +}; + +function SplitPaneHeader({ + label, + onClose, +}: { + label: string; + onClose?: () => void; +}) { + return ( + <div className="flex items-center justify-between gap-2 border-b border-border px-3 py-1.5 bg-muted/30 shrink-0"> + <span className="text-xs font-medium text-muted-foreground truncate">{label}</span> + {onClose && ( + <Button + variant="ghost" + size="icon" + className="h-6 w-6 shrink-0" + aria-label="Close secondary pane" + onClick={onClose} + > + <X className="h-3.5 w-3.5" /> + </Button> + )} + </div> + ); +} + +function SecondaryPlaceholder() { + return ( + <div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground text-center"> + Right-click a page or wiki-link and choose “Open in Split View”, or navigate from this pane. + </div> + ); +} + +export function KiwiSplitView({ + tree, + leftPath, + splitView, + refreshKey, + onLeftNavigate, + onRightNavigate, + onOpenInSplitView, + onSizesChange, + onCloseSecondary, + leftPane, + rightPane, +}: Props) { + const hasSecondary = splitViewHasSecondary(splitView); + const rightPath = splitView.rightPath; + const rightVersion = splitView.rightVersion; + + const renderPage = ( + path: string, + onNavigate: (p: string) => void, + pane: PaneCallbacks, + version?: SplitPaneVersion | null, + ) => ( + <KiwiPage + path={path} + tree={tree} + onNavigate={onNavigate} + onOpenInSplitView={onOpenInSplitView} + versionHash={version?.hash} + readOnly={Boolean(version)} + onEdit={pane.onEdit} + onHistory={pane.onHistory} + onRevealInTree={pane.onRevealInTree} + onToggleStar={pane.onToggleStar} + isStarred={pane.isStarred} + onTogglePin={pane.onTogglePin} + isPinned={pane.isPinned} + onDeleted={pane.onDeleted} + onDuplicated={pane.onDuplicated} + onMoved={pane.onMoved} + onTagClick={pane.onTagClick} + refreshKey={refreshKey} + onPublishedChanged={pane.onPublishedChanged} + /> + ); + + return ( + <ResizablePanelGroup + direction="horizontal" + className="h-full" + onLayout={(sizes) => { + if (sizes.length === 2) { + onSizesChange([sizes[0], sizes[1]]); + } + }} + > + <ResizablePanel + index={0} + defaultSize={splitView.sizes[0]} + className="flex flex-col min-h-0" + > + <SplitPaneHeader label="Primary" /> + <div className="flex-1 min-h-0 overflow-auto kiwi-scroll"> + {renderPage(leftPath, onLeftNavigate, leftPane)} + </div> + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel + index={1} + defaultSize={splitView.sizes[1]} + className="flex flex-col min-h-0" + > + <SplitPaneHeader + label={ + rightVersion + ? `Version ${rightVersion.hash.slice(0, 7)}` + : rightPath ?? "Secondary" + } + onClose={onCloseSecondary} + /> + <div className="flex-1 min-h-0 overflow-auto kiwi-scroll"> + {!hasSecondary ? ( + <SecondaryPlaceholder /> + ) : rightVersion ? ( + renderPage(rightVersion.path, onRightNavigate, rightPane, rightVersion) + ) : rightPath ? ( + renderPage(rightPath, onRightNavigate, rightPane) + ) : ( + <SecondaryPlaceholder /> + )} + </div> + </ResizablePanel> + </ResizablePanelGroup> + ); +} + +export function SplitViewMobileNotice({ onDismiss }: { onDismiss: () => void }) { + return ( + <div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center"> + <p className="text-sm text-muted-foreground max-w-sm"> + Split view is not available on mobile viewports. Widen the window or use a desktop browser. + </p> + <Button variant="outline" size="sm" onClick={onDismiss}> + Dismiss + </Button> + </div> + ); +} diff --git a/ui/src/components/KiwiTree.tsx b/ui/src/components/KiwiTree.tsx index fc567c20..f4676604 100644 --- a/ui/src/components/KiwiTree.tsx +++ b/ui/src/components/KiwiTree.tsx @@ -28,6 +28,7 @@ import { } from "@kw/lib/treeClipboard"; import { ChevronRight, + Columns2, Copy, File, FileAxis3D, @@ -91,6 +92,7 @@ type Props = { autoReveal?: boolean; publishedPaths?: Set<string>; onPublishedChanged?: () => void; + onOpenInSplitView?: (path: string) => void; }; export type KiwiTreeHandle = { @@ -220,6 +222,7 @@ export const KiwiTree = forwardRef<KiwiTreeHandle, Props>(function KiwiTree( autoReveal = true, publishedPaths, onPublishedChanged, + onOpenInSplitView, }, ref, ) { @@ -873,6 +876,7 @@ export const KiwiTree = forwardRef<KiwiTreeHandle, Props>(function KiwiTree( onFolderAltClick={(data) => openFolderRecursive(treeRef.current, data)} publishedPaths={publishedPaths} onPublishedChanged={onPublishedChanged} + onOpenInSplitView={onOpenInSplitView} /> )} </Tree> @@ -906,6 +910,7 @@ type TreeNodeProps = NodeRendererProps<FlatNode> & { onFolderAltClick: (data: FlatNode) => void; publishedPaths?: Set<string>; onPublishedChanged?: () => void; + onOpenInSplitView?: (path: string) => void; }; function FrontmatterWarning({ path, error }: { path: string; error?: string }) { @@ -942,6 +947,7 @@ function TreeNode({ onFolderAltClick, publishedPaths, onPublishedChanged, + onOpenInSplitView, }: TreeNodeProps) { const path = node.id; const isActive = activePath === path; @@ -1520,6 +1526,12 @@ function TreeNode({ <File className="h-3.5 w-3.5" /> Open </ContextMenuItem> + {onOpenInSplitView && isMarkdown(path) && ( + <ContextMenuItem onClick={() => onOpenInSplitView(path)}> + <Columns2 className="h-3.5 w-3.5" /> + Open in Split View + </ContextMenuItem> + )} <ContextMenuSeparator /> {isPublished ? ( <> diff --git a/ui/src/components/ui/resizable.tsx b/ui/src/components/ui/resizable.tsx new file mode 100644 index 00000000..178098f2 --- /dev/null +++ b/ui/src/components/ui/resizable.tsx @@ -0,0 +1,193 @@ +/** + * shadcn-compatible resizable panels (react-resizable-panels API surface). + * Uses pointer-drag resize like the sidebar handle when the package is unavailable. + */ +import * as React from "react"; +import { GripVertical } from "lucide-react"; +import { cn } from "@kw/lib/cn"; + +type Direction = "horizontal" | "vertical"; + +type PanelGroupContextValue = { + direction: Direction; + sizes: number[]; + setSizes: (sizes: number[]) => void; + registerPanel: (index: number, defaultSize: number) => void; + panelCount: number; +}; + +const PanelGroupContext = React.createContext<PanelGroupContextValue | null>(null); + +type PanelGroupProps = React.HTMLAttributes<HTMLDivElement> & { + direction?: Direction; + onLayout?: (sizes: number[]) => void; +}; + +function ResizablePanelGroup({ + className, + direction = "horizontal", + onLayout, + children, + ...props +}: PanelGroupProps) { + const childArray = React.Children.toArray(children); + const panelDefaults = React.useRef<number[]>([]); + const [sizes, setSizesState] = React.useState<number[]>([]); + + const setSizes = React.useCallback( + (next: number[]) => { + setSizesState(next); + onLayout?.(next); + }, + [onLayout], + ); + + const registerPanel = React.useCallback((index: number, defaultSize: number) => { + panelDefaults.current[index] = defaultSize; + setSizesState((prev) => { + if (prev.length >= panelDefaults.current.filter(Boolean).length) return prev; + const total = panelDefaults.current.reduce((sum, v) => sum + (v ?? 0), 0); + if (total <= 0) return prev; + return panelDefaults.current.map((v) => ((v ?? 0) / total) * 100); + }); + }, []); + + const value = React.useMemo( + () => ({ + direction, + sizes, + setSizes, + registerPanel, + panelCount: childArray.filter((child) => + React.isValidElement(child) && (child.type as { displayName?: string }).displayName === "ResizablePanel", + ).length, + }), + [direction, sizes, setSizes, registerPanel, childArray], + ); + + return ( + <PanelGroupContext.Provider value={value}> + <div + className={cn( + "flex h-full w-full", + direction === "vertical" && "flex-col", + className, + )} + data-panel-group-direction={direction} + {...props} + > + {children} + </div> + </PanelGroupContext.Provider> + ); +} + +type PanelProps = React.HTMLAttributes<HTMLDivElement> & { + defaultSize?: number; + minSize?: number; + index?: number; +}; + +function ResizablePanel({ + className, + defaultSize = 50, + minSize = 15, + index = 0, + style, + ...props +}: PanelProps) { + const ctx = React.useContext(PanelGroupContext); + if (!ctx) throw new Error("ResizablePanel must be used within ResizablePanelGroup"); + + React.useEffect(() => { + ctx.registerPanel(index, defaultSize); + }, [ctx, index, defaultSize]); + + const size = ctx.sizes[index] ?? defaultSize; + const flexBasis = `${size}%`; + + return ( + <div + className={cn("min-h-0 min-w-0 overflow-hidden", className)} + style={{ + flexBasis, + flexGrow: 0, + flexShrink: 0, + minWidth: ctx.direction === "horizontal" ? `${minSize}%` : undefined, + minHeight: ctx.direction === "vertical" ? `${minSize}%` : undefined, + ...style, + }} + data-panel-index={index} + {...props} + /> + ); +} +ResizablePanel.displayName = "ResizablePanel"; + +type HandleProps = React.HTMLAttributes<HTMLDivElement> & { + withHandle?: boolean; + panelIndex?: number; +}; + +function ResizableHandle({ + className, + withHandle, + panelIndex = 0, + ...props +}: HandleProps) { + const ctx = React.useContext(PanelGroupContext); + if (!ctx) throw new Error("ResizableHandle must be used within ResizablePanelGroup"); + + const onMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + const group = (e.currentTarget as HTMLElement).parentElement; + if (!group) return; + const rect = group.getBoundingClientRect(); + const isHorizontal = ctx.direction === "horizontal"; + const start = isHorizontal ? e.clientX : e.clientY; + const total = isHorizontal ? rect.width : rect.height; + const startSizes = [...ctx.sizes]; + + const onMove = (ev: MouseEvent) => { + const delta = (isHorizontal ? ev.clientX : ev.clientY) - start; + const deltaPercent = (delta / total) * 100; + const left = Math.max(15, Math.min(85, (startSizes[panelIndex] ?? 50) + deltaPercent)); + const right = 100 - left; + ctx.setSizes([left, right]); + }; + + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; + + return ( + <div + role="separator" + aria-orientation={ctx.direction === "horizontal" ? "vertical" : "horizontal"} + className={cn( + "relative flex shrink-0 items-center justify-center bg-border", + ctx.direction === "horizontal" ? "w-px cursor-col-resize" : "h-px cursor-row-resize", + "after:absolute after:bg-border/80", + ctx.direction === "horizontal" + ? "after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2" + : "after:inset-x-0 after:top-1/2 after:h-1 after:-translate-y-1/2", + className, + )} + onMouseDown={onMouseDown} + {...props} + > + {withHandle && ( + <div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border"> + <GripVertical className="h-2.5 w-2.5" /> + </div> + )} + </div> + ); +} + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; diff --git a/ui/src/lib/kiwiKeybindings.test.ts b/ui/src/lib/kiwiKeybindings.test.ts index ec8f4c94..2d408f5a 100644 --- a/ui/src/lib/kiwiKeybindings.test.ts +++ b/ui/src/lib/kiwiKeybindings.test.ts @@ -7,8 +7,22 @@ import { matchBoundAction, mergeKeybindings, normalizeChord, + normalizeKeyPart, } from "./kiwiKeybindings"; +describe("normalizeKeyPart", () => { + it("canonicalizes single- and multi-character key aliases uniformly", () => { + expect(normalizeKeyPart("Escape")).toBe("escape"); + expect(normalizeKeyPart("Backslash")).toBe("\\"); + expect(normalizeKeyPart("/")).toBe("/"); + expect(normalizeKeyPart("Enter")).toBe("enter"); + expect(normalizeKeyPart("Return")).toBe("enter"); + expect(normalizeKeyPart("Tab")).toBe("tab"); + expect(normalizeKeyPart("F1")).toBe("f1"); + expect(normalizeKeyPart("k")).toBe("k"); + }); +}); + describe("normalizeChord", () => { it("canonicalizes modifier order and aliases", () => { expect(normalizeChord("Ctrl+Shift+B")).toBe("mod+shift+b"); @@ -29,6 +43,18 @@ describe("eventMatchesChord", () => { expect(eventMatchesChord(e, "Mod+K")).toBe(true); }); + it("matches backslash split-view shortcut", () => { + const e = { + key: "\\", + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false, + } as KeyboardEvent; + expect(eventMatchesChord(e, "mod+\\")).toBe(true); + expect(normalizeChord("Mod+\\")).toBe("mod+\\"); + }); + it("matches help shortcut on slash and question mark", () => { const slash = { key: "/", @@ -53,6 +79,7 @@ describe("mergeKeybindings", () => { it("keeps defaults when config is empty", () => { const merged = mergeKeybindings(null); expect(merged.search).toBe(DEFAULT_KEYBINDINGS.search); + expect(merged.toggle_split_view).toBe("mod+\\"); }); it("applies server overrides", () => { @@ -82,6 +109,26 @@ describe("matchBoundAction", () => { } as KeyboardEvent; expect(matchBoundAction(e, bindings)).toBe("graph"); }); + + it("matches toggle_split_view via mod+\\ without affecting other bindings", () => { + const bindings = mergeKeybindings(null); + const splitToggle = { + key: "\\", + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false, + } as KeyboardEvent; + const unrelated = { + key: "s", + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false, + } as KeyboardEvent; + expect(matchBoundAction(splitToggle, bindings)).toBe("toggle_split_view"); + expect(matchBoundAction(unrelated, bindings)).toBe("save"); + }); }); describe("buildChordIndex", () => { diff --git a/ui/src/lib/kiwiKeybindings.ts b/ui/src/lib/kiwiKeybindings.ts index 6c6bc94a..d77c1975 100644 --- a/ui/src/lib/kiwiKeybindings.ts +++ b/ui/src/lib/kiwiKeybindings.ts @@ -19,7 +19,8 @@ export type KeybindingAction = | "shortcuts_help" | "undo" | "focus_tree_filter" - | "close_overlay"; + | "close_overlay" + | "toggle_split_view"; export type ParsedChord = { mod: boolean; @@ -54,8 +55,34 @@ export const DEFAULT_KEYBINDINGS: Record<KeybindingAction, string> = { undo: "mod+z", focus_tree_filter: "mod+alt+f", close_overlay: "escape", + toggle_split_view: "mod+\\", }; +const KEY_ALIASES: Record<string, string> = { + esc: "escape", + escape: "escape", + slash: "/", + "/": "/", + question: "?", + "?": "?", + backslash: "\\", + "\\": "\\", + enter: "enter", + return: "enter", + tab: "tab", + space: "space", + " ": "space", + del: "delete", + delete: "delete", + backspace: "backspace", +}; + +/** Canonicalize a single key token (already lowercased). */ +export function normalizeKeyPart(part: string): string { + const key = part.trim().toLowerCase(); + return KEY_ALIASES[key] ?? key; +} + export function normalizeChord(chord: string): string { const parts = chord.trim().split("+").map((p) => p.trim().toLowerCase()).filter(Boolean); const mods: string[] = []; @@ -79,18 +106,8 @@ export function normalizeChord(chord: string): string { case "option": if (!mods.includes("alt")) mods.push("alt"); break; - case "esc": - case "escape": - key = "escape"; - break; - case "slash": - key = "/"; - break; - case "question": - key = "?"; - break; default: - key = part.length === 1 ? part : part; + key = normalizeKeyPart(part); } } mods.sort(); @@ -126,6 +143,9 @@ export function eventMatchesChord(e: KeyboardEvent, chord: string): boolean { return eventKey === "/" || eventKey === "slash" || eventKey === "?"; } if (parsed.key === "?") return eventKey === "?" || (e.shiftKey && eventKey === "/"); + if (parsed.key === "\\") { + return eventKey === "\\" || eventKey === "backslash"; + } return eventKey === parsed.key; } @@ -176,6 +196,7 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [ { action: "new_page", label: "New page" }, { action: "toggle_editor", label: "Toggle editor" }, { action: "toggle_sidebar", label: "Toggle sidebar" }, + { action: "toggle_split_view", label: "Toggle split view" }, { action: "shortcuts_help", label: "Keyboard shortcuts" }, ], }, diff --git a/ui/src/lib/splitView.test.ts b/ui/src/lib/splitView.test.ts new file mode 100644 index 00000000..1c926912 --- /dev/null +++ b/ui/src/lib/splitView.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { + SPLIT_VIEW_SESSION_KEY, + closeSecondaryPane, + createSplitViewState, + loadSplitViewState, + openPathInSplit, + openVersionInSplit, + saveSplitViewState, + splitViewHasSecondary, + syncSplitViewWithActivePath, + toggleSplitView, + clampSplitSizes, +} from "./splitView"; + +describe("splitView", () => { + const storage = new Map<string, string>(); + + beforeEach(() => { + storage.clear(); + vi.stubGlobal("sessionStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + clear: () => storage.clear(), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("persists and restores split state", () => { + const state = openPathInSplit(createSplitViewState(), "notes/b.md"); + saveSplitViewState(state); + expect(loadSplitViewState()).toEqual(state); + }); + + it("persists custom pane sizes across session reload", () => { + const state = openPathInSplit( + createSplitViewState({ sizes: [35, 65] }), + "notes/b.md", + ); + saveSplitViewState(state); + const restored = loadSplitViewState(); + expect(restored?.sizes).toEqual([35, 65]); + expect(restored?.enabled).toBe(true); + expect(restored?.rightPath).toBe("notes/b.md"); + }); + + it("ignores invalid session payloads", () => { + storage.set(SPLIT_VIEW_SESSION_KEY, "{not json"); + expect(loadSplitViewState()).toBeNull(); + storage.set(SPLIT_VIEW_SESSION_KEY, JSON.stringify({ enabled: true, sizes: [0, 0] })); + expect(loadSplitViewState()?.sizes).toEqual([50, 50]); + }); + + it("opens a page in the secondary pane", () => { + const next = openPathInSplit(createSplitViewState(), "a.md"); + expect(next.enabled).toBe(true); + expect(next.rightPath).toBe("a.md"); + expect(next.rightVersion).toBeNull(); + expect(splitViewHasSecondary(next)).toBe(true); + }); + + it("opens a historical version in the secondary pane", () => { + const next = openVersionInSplit(createSplitViewState(), { path: "a.md", hash: "abc123" }); + expect(next.rightPath).toBeNull(); + expect(next.rightVersion).toEqual({ path: "a.md", hash: "abc123" }); + }); + + it("toggles split view on and off", () => { + const off = createSplitViewState(); + const on = toggleSplitView(off, "page.md"); + expect(on.enabled).toBe(true); + expect(on.rightPath).toBe("page.md"); + const offAgain = toggleSplitView(on, "page.md"); + expect(offAgain.enabled).toBe(false); + }); + + it("toggle preserves custom pane sizes across on/off cycles", () => { + const custom: [number, number] = [35, 65]; + const base = createSplitViewState({ sizes: custom }); + const on = toggleSplitView(base, "a.md"); + expect(on.sizes).toEqual(custom); + const off = toggleSplitView(on, "a.md"); + expect(off.sizes).toEqual(custom); + expect(off.enabled).toBe(false); + }); + + it("double toggle returns to disabled state without side effects", () => { + const initial = createSplitViewState(); + const once = toggleSplitView(initial, "notes/x.md"); + const twice = toggleSplitView(once, "notes/x.md"); + expect(twice).toEqual(initial); + }); + + it("toggle off clears secondary content including version compare", () => { + const withVersion = openVersionInSplit(createSplitViewState(), { path: "a.md", hash: "abc" }); + const cleared = toggleSplitView(withVersion, "a.md"); + expect(cleared.enabled).toBe(false); + expect(splitViewHasSecondary(cleared)).toBe(false); + }); + + it("toggle on when already open via openPathInSplit turns off", () => { + const open = openPathInSplit(createSplitViewState(), "left.md"); + expect(open.enabled).toBe(true); + const closed = toggleSplitView(open, "left.md"); + expect(closed.enabled).toBe(false); + }); + + it("does not enable split without an active page", () => { + const state = toggleSplitView(createSplitViewState(), null); + expect(state.enabled).toBe(false); + }); + + it("closes split view from the secondary pane", () => { + const state = openVersionInSplit(createSplitViewState(), { path: "a.md", hash: "deadbeef" }); + const cleared = closeSecondaryPane(state); + expect(cleared.enabled).toBe(false); + expect(splitViewHasSecondary(cleared)).toBe(false); + expect(cleared.sizes).toEqual(state.sizes); + }); + + it("clamps resize percentages", () => { + expect(clampSplitSizes(10)).toEqual([20, 80]); + expect(clampSplitSizes(90)).toEqual([80, 20]); + expect(clampSplitSizes(55)).toEqual([55, 45]); + }); + + describe("syncSplitViewWithActivePath", () => { + it("disables split view when primary page is cleared", () => { + const open = openPathInSplit(createSplitViewState({ sizes: [40, 60] }), "a.md"); + const synced = syncSplitViewWithActivePath(open, null); + expect(synced.enabled).toBe(false); + expect(synced.sizes).toEqual([40, 60]); + }); + + it("leaves split view unchanged when primary page exists", () => { + const open = openPathInSplit(createSplitViewState(), "a.md"); + expect(syncSplitViewWithActivePath(open, "a.md")).toBe(open); + }); + }); +}); diff --git a/ui/src/lib/splitView.ts b/ui/src/lib/splitView.ts new file mode 100644 index 00000000..dbddb891 --- /dev/null +++ b/ui/src/lib/splitView.ts @@ -0,0 +1,152 @@ +/** + * Split-view state helpers — persisted in sessionStorage per tab. + */ + +export const SPLIT_VIEW_SESSION_KEY = "kiwifs-split-view"; + +export type SplitPaneVersion = { + path: string; + hash: string; +}; + +export type SplitViewState = { + enabled: boolean; + rightPath: string | null; + rightVersion: SplitPaneVersion | null; + /** Percent widths for [left, right] panes. */ + sizes: [number, number]; +}; + +export const DEFAULT_SPLIT_SIZES: [number, number] = [50, 50]; + +export function createSplitViewState( + overrides: Partial<SplitViewState> = {}, +): SplitViewState { + return { + enabled: false, + rightPath: null, + rightVersion: null, + sizes: DEFAULT_SPLIT_SIZES, + ...overrides, + }; +} + +export function loadSplitViewState(): SplitViewState | null { + if (typeof sessionStorage === "undefined") return null; + try { + const raw = sessionStorage.getItem(SPLIT_VIEW_SESSION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial<SplitViewState>; + if (typeof parsed !== "object" || parsed == null) return null; + const sizes = parsed.sizes; + const normalizedSizes: [number, number] = + Array.isArray(sizes) + && sizes.length === 2 + && typeof sizes[0] === "number" + && typeof sizes[1] === "number" + && sizes[0] > 0 + && sizes[1] > 0 + ? [sizes[0], sizes[1]] + : DEFAULT_SPLIT_SIZES; + return createSplitViewState({ + enabled: Boolean(parsed.enabled), + rightPath: typeof parsed.rightPath === "string" ? parsed.rightPath : null, + rightVersion: + parsed.rightVersion + && typeof parsed.rightVersion.path === "string" + && typeof parsed.rightVersion.hash === "string" + ? { path: parsed.rightVersion.path, hash: parsed.rightVersion.hash } + : null, + sizes: normalizedSizes, + }); + } catch { + return null; + } +} + +export function saveSplitViewState(state: SplitViewState): void { + if (typeof sessionStorage === "undefined") return; + try { + sessionStorage.setItem(SPLIT_VIEW_SESSION_KEY, JSON.stringify(state)); + } catch { + // ignore quota / private mode errors + } +} + +export function clearSplitViewState(): void { + if (typeof sessionStorage === "undefined") return; + try { + sessionStorage.removeItem(SPLIT_VIEW_SESSION_KEY); + } catch { + // ignore + } +} + +/** Whether the right pane has content to render. */ +export function splitViewHasSecondary(state: SplitViewState): boolean { + return Boolean(state.rightPath || state.rightVersion); +} + +export function openPathInSplit( + state: SplitViewState, + rightPath: string, +): SplitViewState { + return { + ...state, + enabled: true, + rightPath, + rightVersion: null, + }; +} + +export function openVersionInSplit( + state: SplitViewState, + version: SplitPaneVersion, +): SplitViewState { + return { + ...state, + enabled: true, + rightPath: null, + rightVersion: version, + }; +} + +export function toggleSplitView( + state: SplitViewState, + activePath: string | null, +): SplitViewState { + if (state.enabled) { + return createSplitViewState({ sizes: state.sizes }); + } + if (!activePath) return state; + return { + enabled: true, + rightPath: activePath, + rightVersion: null, + sizes: state.sizes, + }; +} + +/** Disable split view when the primary page is cleared (navigation / space switch). */ +export function syncSplitViewWithActivePath( + state: SplitViewState, + activePath: string | null, +): SplitViewState { + if (state.enabled && !activePath) { + return createSplitViewState({ sizes: state.sizes }); + } + return state; +} + +export function closeSecondaryPane(state: SplitViewState): SplitViewState { + return createSplitViewState({ sizes: state.sizes }); +} + +export function clampSplitSizes( + leftPercent: number, + min = 20, + max = 80, +): [number, number] { + const clamped = Math.max(min, Math.min(max, leftPercent)); + return [clamped, 100 - clamped]; +}