@immediately-run/grove 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +122 -0
  2. package/docs/ENGINE_BOUNDARY.md +233 -0
  3. package/llms.txt +115 -0
  4. package/package.json +82 -0
  5. package/src/App.tsx +131 -0
  6. package/src/GroveApp.css +2774 -0
  7. package/src/GroveWiki.tsx +386 -0
  8. package/src/components/AssetImage.tsx +58 -0
  9. package/src/components/Backlinks.tsx +104 -0
  10. package/src/components/Callout.tsx +26 -0
  11. package/src/components/ChildPages.tsx +40 -0
  12. package/src/components/DefaultLayout.tsx +27 -0
  13. package/src/components/Directory.tsx +65 -0
  14. package/src/components/DirectoryList.test.tsx +275 -0
  15. package/src/components/DirectoryList.tsx +189 -0
  16. package/src/components/DirectoryView.tsx +68 -0
  17. package/src/components/DocList.tsx +109 -0
  18. package/src/components/DocsByTag.tsx +13 -0
  19. package/src/components/Drawer.tsx +45 -0
  20. package/src/components/EntryHeader.tsx +51 -0
  21. package/src/components/FamilyTree.tsx +95 -0
  22. package/src/components/GroveAgent.tsx +264 -0
  23. package/src/components/GroveFooter.tsx +20 -0
  24. package/src/components/GroveNav.tsx +102 -0
  25. package/src/components/Icon.tsx +56 -0
  26. package/src/components/Infobox.tsx +19 -0
  27. package/src/components/Kbd.tsx +10 -0
  28. package/src/components/KeyValue.tsx +32 -0
  29. package/src/components/Lede.tsx +6 -0
  30. package/src/components/More.tsx +10 -0
  31. package/src/components/Outlet.tsx +11 -0
  32. package/src/components/PageMeta.tsx +24 -0
  33. package/src/components/PageView.tsx +98 -0
  34. package/src/components/Quote.tsx +36 -0
  35. package/src/components/RecentlyUpdated.tsx +6 -0
  36. package/src/components/SafeEntryBody.tsx +72 -0
  37. package/src/components/SafeLayout.tsx +35 -0
  38. package/src/components/ScrollToFragment.tsx +63 -0
  39. package/src/components/Search.tsx +127 -0
  40. package/src/components/Sidebar.tsx +118 -0
  41. package/src/components/TableOfContents.test.tsx +163 -0
  42. package/src/components/TableOfContents.tsx +101 -0
  43. package/src/components/TagCloud.tsx +46 -0
  44. package/src/components/TagList.tsx +31 -0
  45. package/src/components/Timeline.tsx +55 -0
  46. package/src/components/Toc.tsx +14 -0
  47. package/src/components/WikiLink.tsx +112 -0
  48. package/src/data/themes.ts +14 -0
  49. package/src/devfs.d.ts +4 -0
  50. package/src/hooks/useContentComponents.ts +122 -0
  51. package/src/hooks/useCorpusMetadata.ts +43 -0
  52. package/src/hooks/useDirectoryListing.ts +56 -0
  53. package/src/hooks/useHeadings.ts +96 -0
  54. package/src/hooks/useOpenWikiBoot.ts +95 -0
  55. package/src/index.css +120 -0
  56. package/src/lib/compose.test.ts +92 -0
  57. package/src/lib/compose.ts +99 -0
  58. package/src/lib/content.test.ts +269 -0
  59. package/src/lib/content.ts +267 -0
  60. package/src/lib/contentRoot.ts +61 -0
  61. package/src/lib/corpusComponents.test.ts +101 -0
  62. package/src/lib/corpusComponents.ts +117 -0
  63. package/src/lib/corpusScan.test.ts +157 -0
  64. package/src/lib/corpusScan.ts +105 -0
  65. package/src/lib/directory.test.ts +216 -0
  66. package/src/lib/directory.ts +262 -0
  67. package/src/lib/fragment.test.ts +88 -0
  68. package/src/lib/fragment.ts +55 -0
  69. package/src/lib/frontmatter.ts +26 -0
  70. package/src/lib/layout.ts +84 -0
  71. package/src/lib/openWiki.test.ts +216 -0
  72. package/src/lib/openWiki.ts +84 -0
  73. package/src/lib/queries.test.ts +74 -0
  74. package/src/lib/queries.ts +84 -0
  75. package/src/lib/safeIntrinsics.test.tsx +99 -0
  76. package/src/lib/safeIntrinsics.tsx +77 -0
  77. package/src/lib/safeRender.test.ts +359 -0
  78. package/src/lib/safeSources.ts +25 -0
  79. package/src/lib/shell.ts +71 -0
  80. package/src/lib/sourceCache.test.ts +66 -0
  81. package/src/lib/sourceCache.ts +42 -0
  82. package/src/lib/tocScroll.test.ts +71 -0
  83. package/src/lib/tocScroll.ts +93 -0
  84. package/src/lib/wiki.test.ts +194 -0
  85. package/src/lib/wiki.ts +175 -0
  86. package/src/lib.ts +54 -0
  87. package/src/main.tsx +19 -0
  88. package/src/mdx.d.ts +9 -0
  89. package/src/mdxComponents.ts +89 -0
  90. package/src/test/setup.ts +19 -0
  91. package/viewer-manifest.schema.json +62 -0
  92. package/viewer.manifest.json +267 -0
@@ -0,0 +1,109 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useCallback } from 'react';
3
+ import { Link, useFileMetadata, useMetadataQuery } from '@immediately-run/sdk';
4
+ import { isContentEntry, keyToHref, slugToKey } from '../lib/content';
5
+ import { queryPaths } from '../lib/wiki';
6
+
7
+ interface Props {
8
+ shape?: 'feed' | 'grid';
9
+ title?: string | null;
10
+ slugs?: string;
11
+ tag?: string;
12
+ limit?: string | number;
13
+ sort?: 'date' | 'title';
14
+ }
15
+
16
+ function tagsOf(meta: any): string[] {
17
+ return Array.isArray(meta?.tags) ? meta.tags.filter((t: string) => !t.startsWith('ui/')) : [];
18
+ }
19
+
20
+ // One entry as a feed row — reads its own frontmatter so the hook count is stable.
21
+ function Row({ path }: { path: string }) {
22
+ const meta = useFileMetadata(path) as any;
23
+ if (!meta) return <div className="gdl-row"><div className="sk sk-line" style={{ width: '50%' }} /></div>;
24
+ return (
25
+ <Link href={keyToHref(path)} className="gdl-row">
26
+ <div>
27
+ <div className="gdl-row__t">{(meta.title || path).replace(/\.$/, '')}</div>
28
+ {meta.description && <div className="gdl-row__d">{meta.description}</div>}
29
+ <div className="gdl-row__tags">
30
+ {tagsOf(meta).slice(0, 3).map((t) => (
31
+ <span key={t} className="grove-tag">#{t}</span>
32
+ ))}
33
+ </div>
34
+ </div>
35
+ <div className="gdl-row__meta">{meta.date}</div>
36
+ </Link>
37
+ );
38
+ }
39
+
40
+ // One entry as a grid card — link-graph placeholder pic + a footer of meta.
41
+ function CardTile({ path }: { path: string }) {
42
+ const meta = useFileMetadata(path) as any;
43
+ if (!meta) return <div className="gdl-card"><div className="gdl-card__pic" /></div>;
44
+ return (
45
+ <Link href={keyToHref(path)} className="gdl-card">
46
+ <div className="gdl-card__pic" />
47
+ <div className="gdl-card__foot">
48
+ <div className="gdl-card__t">{(meta.title || path).replace(/\.$/, '')}</div>
49
+ {meta.description && <div className="gdl-card__d">{meta.description}</div>}
50
+ <div className="gdl-card__tags">
51
+ {tagsOf(meta).slice(0, 3).map((t) => (
52
+ <span key={t} className="grove-tag">#{t}</span>
53
+ ))}
54
+ </div>
55
+ </div>
56
+ </Link>
57
+ );
58
+ }
59
+
60
+ // Import-free engine component: a frontmatter-driven index of entries, in a feed
61
+ // or grid shape. `slugs` pins an explicit ordered set; otherwise it queries the
62
+ // whole content space (optionally filtered by `tag`), excluding view/index pages.
63
+ export default function DocList({ shape = 'feed', title, slugs, tag, limit, sort = 'date' }: Props) {
64
+ const queryFn = useCallback(
65
+ (fm: Record<string, any>) => {
66
+ const keys = Object.keys(fm).filter((p) => {
67
+ if (!isContentEntry(p)) return false;
68
+ const m = fm[p] || {};
69
+ if (m.view) return false;
70
+ if (tag && !(Array.isArray(m.tags) && m.tags.includes(tag))) return false;
71
+ return true;
72
+ });
73
+ keys.sort((a, b) => {
74
+ if (sort === 'title') return (fm[a].title || a) < (fm[b].title || b) ? -1 : 1;
75
+ return String(fm[b].date || '').localeCompare(String(fm[a].date || ''));
76
+ });
77
+ return keys;
78
+ },
79
+ [tag, sort]
80
+ );
81
+ const queried = useMetadataQuery(queryFn);
82
+ const loaded = Array.isArray(queried);
83
+
84
+ let paths: string[] = slugs
85
+ ? slugs.split(',').map((s) => slugToKey(s.trim())).filter(Boolean)
86
+ : queryPaths(queried);
87
+ const n = limit ? Number(limit) : undefined;
88
+ if (n && paths.length > n) paths = paths.slice(0, n);
89
+
90
+ return (
91
+ <div className="grove-doclist-wrap">
92
+ {title ? (
93
+ <div className="grove-doclist__head">
94
+ <h2>{title}</h2>
95
+ <span className="n">{paths.length} {paths.length === 1 ? 'entry' : 'entries'}</span>
96
+ </div>
97
+ ) : null}
98
+ {!slugs && loaded && paths.length === 0 ? (
99
+ <p className="grove-search__empty">No entries yet.</p>
100
+ ) : (
101
+ <div className="grove-doclist" data-shape={shape}>
102
+ {paths.map((p: string) =>
103
+ shape === 'grid' ? <CardTile key={p} path={p} /> : <Row key={p} path={p} />
104
+ )}
105
+ </div>
106
+ )}
107
+ </div>
108
+ );
109
+ }
@@ -0,0 +1,13 @@
1
+ import DocList from './DocList';
2
+
3
+ // `<DocsByTag tag>` — the body of a tag page: a `#tag` header + the filtered feed.
4
+ export default function DocsByTag({ tag, shape = 'feed' }: { tag: string; shape?: 'feed' | 'grid' }) {
5
+ return (
6
+ <div>
7
+ <div className="grove-doclist__head">
8
+ <h2><span className="grove-tag" data-active="1">#{tag}</span></h2>
9
+ </div>
10
+ <DocList shape={shape} tag={tag} />
11
+ </div>
12
+ );
13
+ }
@@ -0,0 +1,45 @@
1
+ import { Link } from '@immediately-run/sdk';
2
+ import Sidebar from './Sidebar';
3
+ import Icon from './Icon';
4
+
5
+ interface NavItem {
6
+ href: string;
7
+ label: string;
8
+ cur: boolean;
9
+ }
10
+
11
+ // `.grove-drawer` — the mobile nav drawer: site mark + nav items + the full
12
+ // namespace tree, over a dismiss scrim.
13
+ export default function Drawer({
14
+ siteTitle,
15
+ nav,
16
+ onClose,
17
+ }: {
18
+ siteTitle: string;
19
+ nav: NavItem[];
20
+ onClose: () => void;
21
+ }) {
22
+ return (
23
+ <div className="grove-drawer" onClick={onClose}>
24
+ <div className="grove-drawer__panel" onClick={(e) => e.stopPropagation()}>
25
+ <div className="grove-drawer__h">
26
+ <span className="mk" />
27
+ <span className="t">{siteTitle}</span>
28
+ <button className="x" aria-label="Close menu" onClick={onClose}>
29
+ <Icon name="x" />
30
+ </button>
31
+ </div>
32
+ {nav.length ? (
33
+ <nav className="grove-drawer__nav">
34
+ {nav.map((n) => (
35
+ <Link key={n.href} href={n.href} data-cur={n.cur ? '1' : '0'} onClick={onClose}>
36
+ {n.label}
37
+ </Link>
38
+ ))}
39
+ </nav>
40
+ ) : null}
41
+ <Sidebar />
42
+ </div>
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,51 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useState } from 'react';
3
+ import { requestEdit, useFileMetadata } from '@immediately-run/sdk';
4
+ import { keyToRepoRel } from '../lib/content';
5
+ import { crumb } from '../lib/wiki';
6
+ import Icon from './Icon';
7
+
8
+ // The entry's header block: breadcrumb, title (optional gradient), meta row
9
+ // (date · reading time · tags) and the unobtrusive edit affordance. Rendered by
10
+ // <PageView> at the top of every entry.
11
+ export default function EntryHeader({
12
+ entryKey,
13
+ writable,
14
+ mins,
15
+ }: {
16
+ entryKey: string;
17
+ writable: boolean;
18
+ mins: number;
19
+ }) {
20
+ const meta = useFileMetadata(entryKey) as any;
21
+ const [busy, setBusy] = useState(false);
22
+ if (!meta) return null;
23
+ const tags: string[] = Array.isArray(meta.tags) ? meta.tags.filter((t: string) => !t.startsWith('ui/')) : [];
24
+ const cr = crumb(entryKey);
25
+ const edit = () => {
26
+ setBusy(true);
27
+ requestEdit({ path: keyToRepoRel(entryKey) })
28
+ .catch(() => undefined)
29
+ .finally(() => setBusy(false));
30
+ };
31
+ return (
32
+ <header className="grove-entry-header">
33
+ {cr.includes('/') ? <nav className="crumb">{cr}</nav> : null}
34
+ <h1 className={meta.grad ? 'grad' : ''}>{meta.title || cr}</h1>
35
+ <div className="grove-meta">
36
+ {meta.date && <span>{meta.date}</span>}
37
+ {mins ? <span>→ {mins} min read</span> : null}
38
+ {tags.length ? <span className="dot">·</span> : null}
39
+ {tags.map((t) => (
40
+ <span key={t} className="grove-tag">#{t}</span>
41
+ ))}
42
+ {writable && (
43
+ <button className="grove-edit-affordance" data-busy={busy ? '1' : '0'} onClick={edit}>
44
+ <Icon name="pencil" />
45
+ {busy ? 'Opening editor…' : 'Edit'}
46
+ </button>
47
+ )}
48
+ </div>
49
+ </header>
50
+ );
51
+ }
@@ -0,0 +1,95 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useContext } from 'react';
3
+ import { useMetadataQuery } from '@immediately-run/sdk';
4
+ import type { Metadata } from '@immediately-run/sdk';
5
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
6
+ import { sandboxPathToKey } from '../lib/content';
7
+ import { familyTreeQuery } from '../lib/queries';
8
+ import type { FamilyNodeRecord } from '../lib/queries';
9
+
10
+ interface Node {
11
+ id: string;
12
+ label: string;
13
+ group: string;
14
+ x: number;
15
+ y: number;
16
+ }
17
+
18
+ // `<FamilyTree/>` — the relational extension view. Reads each entry's relationship
19
+ // frontmatter (`parent`/`manager`/`team`) and draws the link-graph motif made
20
+ // literal: group hubs joined by hairline edges to their members. Genealogy
21
+ // (`parent`/`house`) and org charts (`team`/`manager`) share the same rendering.
22
+ export default function FamilyTree() {
23
+ const ctx = useContext(TinkerableContext) as any;
24
+ const currentKey = sandboxPathToKey(ctx?.navigationState?.sandboxPath || '/');
25
+
26
+ // Records, not tab-encoded paths (R3-276a).
27
+ const q = useMetadataQuery<Metadata, FamilyNodeRecord>(familyTreeQuery);
28
+ const rows: FamilyNodeRecord[] = Array.isArray(q) ? q : [];
29
+
30
+ if (!rows.length) {
31
+ return <p className="grove-search__empty">No relationships defined.</p>;
32
+ }
33
+
34
+ // Deterministic layout: hubs evenly across the width, members stacked beneath.
35
+ const groups: Record<string, typeof rows> = {};
36
+ rows.forEach((r: any) => (groups[r.group] = groups[r.group] || []).push(r));
37
+ const groupNames = Object.keys(groups).sort();
38
+ const W = 720;
39
+ const colW = W / groupNames.length;
40
+ const hubs: Node[] = [];
41
+ const members: Node[] = [];
42
+ const edges: { x1: number; y1: number; x2: number; y2: number; cur: boolean }[] = [];
43
+ groupNames.forEach((g, gi) => {
44
+ const cx = colW * gi + colW / 2;
45
+ const hub: Node = { id: g, label: g, group: g, x: cx, y: 46 };
46
+ hubs.push(hub);
47
+ groups[g].forEach((m: any, mi: number) => {
48
+ const my = 120 + mi * 46;
49
+ const mn: Node = { id: m.path, label: m.label, group: g, x: cx, y: my };
50
+ members.push(mn);
51
+ edges.push({ x1: cx, y1: 56, x2: cx, y2: my, cur: m.path === currentKey });
52
+ });
53
+ });
54
+ const maxRows = Math.max(...groupNames.map((g) => groups[g].length));
55
+ const H = 120 + maxRows * 46;
56
+
57
+ return (
58
+ <div className="grove-graph">
59
+ <svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ maxWidth: W }}>
60
+ {edges.map((e, i) => (
61
+ <line
62
+ key={i}
63
+ x1={e.x1}
64
+ y1={e.y1}
65
+ x2={e.x2}
66
+ y2={e.y2}
67
+ stroke={e.cur ? 'var(--accent)' : 'var(--line-2)'}
68
+ strokeWidth={1}
69
+ />
70
+ ))}
71
+ {hubs.map((h) => (
72
+ <g key={h.id}>
73
+ <circle cx={h.x} cy={h.y} r={7} fill="var(--accent-violet)" />
74
+ <text x={h.x} y={h.y - 14} textAnchor="middle" fill="var(--ink)" fontSize={13} fontFamily="var(--disp)">
75
+ {h.label}
76
+ </text>
77
+ </g>
78
+ ))}
79
+ {members.map((m) => (
80
+ <g key={m.id}>
81
+ <circle
82
+ cx={m.x}
83
+ cy={m.y}
84
+ r={5}
85
+ fill={m.id === currentKey ? 'var(--accent)' : 'var(--ink-3)'}
86
+ />
87
+ <text x={m.x + 12} y={m.y + 4} fill="var(--ink-2)" fontSize={12} fontFamily="var(--sans)">
88
+ {m.label}
89
+ </text>
90
+ </g>
91
+ ))}
92
+ </svg>
93
+ </div>
94
+ );
95
+ }
@@ -0,0 +1,264 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { chat, requestEdit, useChatProvider } from '@immediately-run/sdk';
4
+ import { keyToRepoRel } from '../lib/content';
5
+ import Icon from './Icon';
6
+
7
+ interface Msg {
8
+ role: 'user' | 'assistant';
9
+ text: string;
10
+ }
11
+
12
+ type Banner = null | 'no-key' | 'forbidden' | 'read-only' | 'error';
13
+
14
+ const CHIPS = [
15
+ 'Add an entry…',
16
+ 'Fix broken links here',
17
+ 'Summarize this entry',
18
+ 'Reorganize the sidebar',
19
+ 'Add a timeline view',
20
+ ];
21
+
22
+ // `.grove-agent` (brief 06) — Grove's own, always-present, capability-scoped agent.
23
+ // Resting state: the thin "Ask Grove…" input line pinned to the bottom (approach 1,
24
+ // the recommended posture — content stays fully visible until invoked). Expands into
25
+ // a bottom dock (desktop) / sheet (mobile). Rides the host `llm.chat@1` slot via the
26
+ // SDK `chat()` stream; degrades to the SP-7 connect-a-key / forbidden / read-only
27
+ // banners rather than faking a host surface.
28
+ export default function GroveAgent({ writable, entryKey, entryTitle }: { writable: boolean; entryKey: string; entryTitle: string }) {
29
+ const provider = useChatProvider();
30
+ const [open, setOpen] = useState(false);
31
+ const [detent, setDetent] = useState<'half' | 'full'>('half');
32
+ const [resting, setResting] = useState('');
33
+ const [draft, setDraft] = useState('');
34
+ const [msgs, setMsgs] = useState<Msg[]>([]);
35
+ const [streaming, setStreaming] = useState(false);
36
+ const [sendBanner, setSendBanner] = useState<Banner>(null);
37
+ const bodyRef = useRef<HTMLDivElement>(null);
38
+ const footRef = useRef<HTMLInputElement>(null);
39
+
40
+ // Derived banner: read-only (no write grant) / no-key (no provider bound, SP-7)
41
+ // come straight from props+provider; send-time errors layer on top.
42
+ const staticBanner: Banner = !writable ? 'read-only' : provider === null ? 'no-key' : null;
43
+ const banner: Banner = sendBanner || staticBanner;
44
+
45
+ useEffect(() => {
46
+ if (open) requestAnimationFrame(() => footRef.current?.focus());
47
+ }, [open]);
48
+ useEffect(() => {
49
+ bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight });
50
+ }, [msgs, streaming]);
51
+
52
+ async function send(text: string) {
53
+ const q = text.trim();
54
+ if (!q || streaming) return;
55
+ setOpen(true);
56
+ setDraft('');
57
+ setResting('');
58
+ const history = [...msgs, { role: 'user' as const, text: q }];
59
+ setMsgs([...history, { role: 'assistant', text: '' }]);
60
+ setStreaming(true);
61
+ setSendBanner(null);
62
+
63
+ try {
64
+ const req = {
65
+ messages: [
66
+ {
67
+ role: 'system' as const,
68
+ content: [
69
+ {
70
+ type: 'text' as const,
71
+ text: `You are Grove, the embedded agent for this MDX wiki. Entries live as .mdx files under /content and interlink with markdown links and frontmatter tags. The reader is currently on "${entryTitle}". Be concise and wiki-fluent; when you propose changes, describe them plainly. You never see secrets and privileged writes are confirmed by the host.`,
72
+ },
73
+ ],
74
+ },
75
+ ...history.map((m) => ({
76
+ role: m.role,
77
+ content: [{ type: 'text' as const, text: m.text }],
78
+ })),
79
+ ],
80
+ modelHint: 'smart' as const,
81
+ };
82
+ let acc = '';
83
+ for await (const d of chat(req)) {
84
+ if (d.type === 'text-delta') {
85
+ acc += d.text;
86
+ setMsgs((prev) => {
87
+ const next = prev.slice();
88
+ next[next.length - 1] = { role: 'assistant', text: acc };
89
+ return next;
90
+ });
91
+ }
92
+ }
93
+ if (!acc) throw new Error('empty');
94
+ } catch (e: any) {
95
+ const code = e?.code || e?.message;
96
+ setSendBanner(code === 'auth-required' ? 'no-key' : code === 'forbidden' ? 'forbidden' : 'error');
97
+ // Drop the empty assistant placeholder.
98
+ setMsgs((prev) => (prev.length && prev[prev.length - 1].text === '' ? prev.slice(0, -1) : prev));
99
+ } finally {
100
+ setStreaming(false);
101
+ }
102
+ }
103
+
104
+ const restingDisabled = banner === 'read-only';
105
+
106
+ return (
107
+ <div className="grove-agent">
108
+ {/* Resting: the always-present thin input line (approach 1). */}
109
+ {!open && (
110
+ <form
111
+ className="ga-line"
112
+ onSubmit={(e) => {
113
+ e.preventDefault();
114
+ if (restingDisabled) setOpen(true);
115
+ else send(resting);
116
+ }}
117
+ >
118
+ <span className="mk" />
119
+ <input
120
+ value={resting}
121
+ placeholder={restingDisabled ? 'Ask Grove about this wiki…' : `Ask Grove to change "${entryTitle}"…`}
122
+ onChange={(e) => setResting(e.target.value)}
123
+ onFocus={() => setOpen(true)}
124
+ aria-label="Ask Grove"
125
+ />
126
+ <button className="go" type="submit" aria-label="Ask Grove">
127
+ <Icon name="send" />
128
+ </button>
129
+ </form>
130
+ )}
131
+
132
+ {/* Expanded: dock (desktop) / sheet (mobile). */}
133
+ {open && (
134
+ <>
135
+ <div className="ga-scrim" onClick={() => setOpen(false)} />
136
+ <div className="ga-panel" data-detent={detent}>
137
+ <div className="ga-panel-inner">
138
+ <div className="ga-head">
139
+ <span className="grip" onClick={() => setDetent((d) => (d === 'half' ? 'full' : 'half'))} />
140
+ <span className="mk" />
141
+ <span className="id">Grove</span>
142
+ <span className="scope">
143
+ <Icon name="shield" />
144
+ scoped to this wiki
145
+ </span>
146
+ <span className="grow">
147
+ <button className="ic" aria-label={detent === 'half' ? 'Expand' : 'Collapse'} onClick={() => setDetent((d) => (d === 'half' ? 'full' : 'half'))}>
148
+ <Icon name={detent === 'half' ? 'chevron-down' : 'chevron-right'} />
149
+ </button>
150
+ <button className="ic" aria-label="Close" onClick={() => setOpen(false)}>
151
+ <Icon name="x" />
152
+ </button>
153
+ </span>
154
+ </div>
155
+
156
+ {banner && <AgentBanner banner={banner} />}
157
+
158
+ <div className="ga-body" ref={bodyRef}>
159
+ {msgs.length === 0 ? (
160
+ <>
161
+ <div className="ga-explain">
162
+ <b>Grove's agent</b> knows this wiki's entries, tags, and components. Ask it to add or
163
+ change content in plain language — it proposes the edit, and the host confirms the write.
164
+ </div>
165
+ {!restingDisabled && (
166
+ <div className="ga-chips">
167
+ {CHIPS.map((c) => (
168
+ <button key={c} className="ga-chip" onClick={() => send(c)}>
169
+ {c}
170
+ </button>
171
+ ))}
172
+ </div>
173
+ )}
174
+ </>
175
+ ) : (
176
+ msgs.map((m, i) => (
177
+ <div key={i} className={`ga-msg ${m.role}`}>
178
+ <span className={`ga-msg__av ${m.role === 'assistant' ? 'grove' : 'user'}`}>
179
+ {m.role === 'user' ? 'you' : ''}
180
+ </span>
181
+ <div className="ga-msg__b">
182
+ <div className="ga-msg__who">{m.role === 'assistant' ? 'Grove' : 'You'}</div>
183
+ <div className="ga-msg__txt">
184
+ {m.text}
185
+ {streaming && i === msgs.length - 1 && m.role === 'assistant' ? <span className="ga-cursor" /> : null}
186
+ </div>
187
+ </div>
188
+ </div>
189
+ ))
190
+ )}
191
+ </div>
192
+
193
+ <div className="ga-foot">
194
+ <form
195
+ className="ga-foot__row"
196
+ onSubmit={(e) => {
197
+ e.preventDefault();
198
+ send(draft);
199
+ }}
200
+ >
201
+ <input
202
+ ref={footRef}
203
+ value={draft}
204
+ placeholder="Ask Grove…"
205
+ disabled={streaming || restingDisabled}
206
+ onChange={(e) => setDraft(e.target.value)}
207
+ />
208
+ <button className="go" type="submit" disabled={streaming || restingDisabled || !draft.trim()} aria-label="Send">
209
+ <Icon name={streaming ? 'stop' : 'send'} />
210
+ </button>
211
+ </form>
212
+ <div className="ga-foot__hand">
213
+ <span>Grove's own agent · scoped to your grants</span>
214
+ <a
215
+ onClick={() => requestEdit({ path: keyToRepoRel(entryKey) }).catch(() => undefined)}
216
+ role="button"
217
+ tabIndex={0}
218
+ >
219
+ <Icon name="external" />
220
+ Open in the workbench
221
+ </a>
222
+ </div>
223
+ </div>
224
+ </div>
225
+ </div>
226
+ </>
227
+ )}
228
+ </div>
229
+ );
230
+ }
231
+
232
+ // The no-key / forbidden / read-only / error states — calm explanations that hand
233
+ // to the host secret flow rather than faking a key prompt.
234
+ function AgentBanner({ banner }: { banner: Banner }) {
235
+ const copy: Record<string, { t: string; d: React.ReactNode }> = {
236
+ 'no-key': {
237
+ t: 'Connect a key to use the agent.',
238
+ d: 'Grove rides whichever model provider you connect in the host. Add a key to start asking.',
239
+ },
240
+ forbidden: {
241
+ t: 'The agent isn’t available here.',
242
+ d: "This Grove wasn't granted the chat capability. Reading still works as normal.",
243
+ },
244
+ 'read-only': {
245
+ t: 'You’re viewing read-only.',
246
+ d: 'The agent can explain this wiki, but changes need write access to this space.',
247
+ },
248
+ error: {
249
+ t: 'The agent hit a snag.',
250
+ d: 'The model or backend errored. Try again in a moment.',
251
+ },
252
+ };
253
+ const c = copy[banner as string];
254
+ if (!c) return null;
255
+ return (
256
+ <div className="ga-banner">
257
+ <Icon name="alert" />
258
+ <div>
259
+ <div className="ga-banner__t">{c.t}</div>
260
+ <div className="ga-banner__d">{c.d}</div>
261
+ </div>
262
+ </div>
263
+ );
264
+ }
@@ -0,0 +1,20 @@
1
+ import { Link } from '@immediately-run/sdk';
2
+ import { useShell } from '../lib/shell';
3
+
4
+ // `<GroveFooter/>` — the site footer. Import-free in MDX; reads the nav items from
5
+ // the shell context so a layout just places `<GroveFooter/>`.
6
+ export default function GroveFooter() {
7
+ const { navItems } = useShell();
8
+ return (
9
+ <footer className="grove-footer">
10
+ <div className="grove-footer__links">
11
+ {navItems.slice(0, 4).map((n) => (
12
+ <Link key={n.key} href={n.href}>
13
+ {n.label}
14
+ </Link>
15
+ ))}
16
+ </div>
17
+ <div className="grove-footer__meta">built with grove</div>
18
+ </footer>
19
+ );
20
+ }