@immediately-run/grove 0.1.2 → 0.1.3

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 (77) hide show
  1. package/README.md +43 -115
  2. package/llms.txt +5 -2
  3. package/package.json +10 -8
  4. package/src/App.tsx +11 -7
  5. package/src/GroveApp.css +484 -152
  6. package/src/GroveWiki.tsx +105 -30
  7. package/src/components/AssetImage.tsx +1 -12
  8. package/src/components/Backlinks.tsx +2 -2
  9. package/src/components/Catalogue.test.tsx +157 -0
  10. package/src/components/ContentTheme.test.tsx +146 -0
  11. package/src/components/ContentTheme.tsx +111 -0
  12. package/src/components/DocList.infinite.test.tsx +177 -0
  13. package/src/components/DocList.tsx +82 -9
  14. package/src/components/Drawer.tsx +14 -2
  15. package/src/components/EntryHeader.tsx +23 -12
  16. package/src/components/EntryImage.test.tsx +210 -0
  17. package/src/components/EntryImage.tsx +47 -0
  18. package/src/components/Galleries.test.tsx +98 -0
  19. package/src/components/GroveAgent.test.tsx +330 -0
  20. package/src/components/GroveAgent.tsx +269 -150
  21. package/src/components/GroveNav.test.tsx +145 -5
  22. package/src/components/GroveNav.tsx +51 -8
  23. package/src/components/Icon.tsx +0 -1
  24. package/src/components/InlineProse.tsx +37 -0
  25. package/src/components/LayoutGallery.tsx +65 -0
  26. package/src/components/PageView.tsx +18 -3
  27. package/src/components/Search.test.tsx +113 -0
  28. package/src/components/Search.tsx +53 -18
  29. package/src/components/Sidebar.test.tsx +135 -0
  30. package/src/components/Sidebar.tsx +114 -12
  31. package/src/components/TableOfContents.test.tsx +27 -16
  32. package/src/components/ThemeAssets.test.tsx +109 -0
  33. package/src/components/ThemeAssets.tsx +63 -0
  34. package/src/components/ThemeGallery.tsx +31 -0
  35. package/src/components/Timeline.tsx +5 -2
  36. package/src/components/WikiLink.tsx +2 -2
  37. package/src/data/catalogue.ts +54 -0
  38. package/src/data/themeFonts.ts +58 -0
  39. package/src/data/themes.ts +16 -7
  40. package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
  41. package/src/hooks/useContentComponents.ts +1 -1
  42. package/src/hooks/useEditAffordance.ts +17 -4
  43. package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
  44. package/src/hooks/useOverlayFocusDismiss.ts +118 -0
  45. package/src/hooks/useScrollReset.test.tsx +132 -0
  46. package/src/hooks/useScrollReset.ts +52 -0
  47. package/src/index.css +9 -2
  48. package/src/lib/agentPrompt.test.ts +96 -0
  49. package/src/lib/agentPrompt.ts +86 -0
  50. package/src/lib/agentTools.test.ts +115 -0
  51. package/src/lib/agentTools.ts +132 -0
  52. package/src/lib/agentTranscript.ts +50 -0
  53. package/src/lib/assetPath.test.ts +36 -0
  54. package/src/lib/assetPath.ts +42 -0
  55. package/src/lib/collectionCalls.test.ts +69 -0
  56. package/src/lib/content.test.ts +10 -2
  57. package/src/lib/content.ts +7 -0
  58. package/src/lib/contentStylesheet.test.ts +80 -0
  59. package/src/lib/contentStylesheet.ts +103 -0
  60. package/src/lib/corpusScan.test.ts +37 -3
  61. package/src/lib/corpusScan.ts +17 -2
  62. package/src/lib/inlineProse.parity.test.ts +52 -0
  63. package/src/lib/layout.ts +29 -0
  64. package/src/lib/pageVariants.test.tsx +87 -0
  65. package/src/lib/queries.test.ts +33 -1
  66. package/src/lib/queries.ts +33 -1
  67. package/src/lib/reachCard.test.ts +94 -0
  68. package/src/lib/reachCard.ts +112 -0
  69. package/src/lib/shell.ts +7 -0
  70. package/src/lib/starterSweep.test.tsx +160 -0
  71. package/src/lib/starterSweep.ts +97 -0
  72. package/src/lib/themeAssets.test.ts +135 -0
  73. package/src/lib/themeAssets.ts +143 -0
  74. package/src/lib/themeSelection.test.ts +2 -2
  75. package/src/mdxComponents.ts +4 -0
  76. package/viewer-manifest.schema.json +37 -0
  77. package/viewer.manifest.json +132 -6
@@ -1,130 +1,243 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { useEffect, useRef, useState } from 'react';
3
- import { chat, useChatProvider } from '@immediately-run/sdk';
4
- import { useShell } from '../lib/shell';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ runAgent,
4
+ createChatModelClient,
5
+ useChatProviderState,
6
+ useCatalog,
7
+ useAllMetadata,
8
+ useAgentContext,
9
+ renderAgentContext,
10
+ type AgentMessage,
11
+ } from '@immediately-run/sdk';
12
+ import { useShell, EDIT_REFUSED_NOTICE } from '../lib/shell';
13
+ import { useHeadings, useActiveHeading } from '../hooks/useHeadings';
14
+ import { useOverlayFocusDismiss } from '../hooks/useOverlayFocusDismiss';
15
+ import { getContentRoot } from '../lib/contentRoot';
16
+ import { createReadEntryTool, createGroveMetadataTool, groveAgentTools, toolExecutor } from '../lib/agentTools';
17
+ import { buildSystemPrompt } from '../lib/agentPrompt';
18
+ import { computeReachRows, reachChips, showEgressDisclosure, EGRESS_DISCLOSURE } from '../lib/reachCard';
19
+ import { transcriptToRows, toolActivityLine, type AgentRow } from '../lib/agentTranscript';
20
+ import { safeSources } from '../lib/safeSources';
5
21
  import Icon from './Icon';
6
22
 
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 { openEditor } = useShell();
23
+ // `.grove-agent` — Grove's own embedded agent (GROVE_AGENT_SPEC).
24
+ //
25
+ // The surface is a FUNCTION of the session's envelope (R-GA-1): the reach card in
26
+ // the expanded header is computed from the provider three-state, the `llm:chat`
27
+ // grant (the grant-filtered catalog), mount writability, and source trust — never
28
+ // hand-written copy. The loop rides the workbench's seam — SDK `runAgent` over the
29
+ // host `llm.chat` slot — and its two tools are the mount-chrooted `read_entry` and
30
+ // the index query (R-GA-2). The widget never writes (R-GA-3): every change is a
31
+ // hand-off to the editor / workbench. Read-only never blocks Q&A (R-GA-5). When a
32
+ // provider is bound the egress line is shown unconditionally (R-GA-6). Every
33
+ // corpus-derived byte entering the loop is fenced (R-GA-7).
34
+ export default function GroveAgent({
35
+ writable,
36
+ entryKey,
37
+ entryTitle,
38
+ }: {
39
+ writable: boolean;
40
+ entryKey: string;
41
+ entryTitle: string;
42
+ }) {
43
+ const providerState = useChatProviderState();
44
+ const catalog = useCatalog();
45
+ const index = useAllMetadata();
46
+ const headings = useHeadings(entryKey);
47
+ const activeHeading = useActiveHeading(headings);
48
+ const { openEditor, editRefused } = useShell();
31
49
  const [open, setOpen] = useState(false);
32
50
  const [detent, setDetent] = useState<'half' | 'full'>('half');
33
51
  const [resting, setResting] = useState('');
34
52
  const [draft, setDraft] = useState('');
35
- const [msgs, setMsgs] = useState<Msg[]>([]);
53
+ const [rows, setRows] = useState<AgentRow[]>([]);
36
54
  const [streaming, setStreaming] = useState(false);
37
- const [sendBanner, setSendBanner] = useState<Banner>(null);
55
+ const [errorToast, setErrorToast] = useState<string | null>(null);
38
56
  const bodyRef = useRef<HTMLDivElement>(null);
39
57
  const footRef = useRef<HTMLInputElement>(null);
58
+ // One AbortController per run (R3-608): the composer's stop control aborts the
59
+ // in-flight run through the SDK signal the loop already honours.
60
+ const abortRef = useRef<AbortController | null>(null);
61
+ // The panel carries the dialog contract (R3-608): focus in, Tab trapped,
62
+ // Escape, focus return — the shared hook, stack-aware.
63
+ const dialogRef = useOverlayFocusDismiss(open, () => setOpen(false));
64
+
65
+ // The envelope, computed (R-GA-1). `llm:chat` appears in the grant-filtered
66
+ // catalog iff this app holds the consent — an ungranted fork reads a DISTINCT
67
+ // cause from a user without a key (G-GA-10).
68
+ const chatGranted = catalog.some((m) => m.name === 'llm:chat');
69
+ const context = useAgentContext({ entryPath: entryKey, entryTitle, heading: activeHeading || undefined });
70
+ const reachRows = useMemo(
71
+ () => computeReachRows({ providerState, chatGranted, writable, sourceShared: context.sourceShared }),
72
+ [providerState, chatGranted, writable, context.sourceShared],
73
+ );
74
+ const chips = useMemo(() => reachChips(reachRows), [reachRows]);
75
+ const canAsk = providerState.status === 'configured' && chatGranted;
76
+
77
+ // Read at CALL time (a scan may land, the reader may navigate) — refs kept fresh
78
+ // in an effect, never written during render (the react-hooks compiler rejects that).
79
+ const indexRef = useRef(index);
80
+ const contextRef = useRef(context);
81
+ const entryRef = useRef(entryKey);
82
+ const headingRef = useRef(activeHeading);
83
+ useEffect(() => {
84
+ indexRef.current = index;
85
+ contextRef.current = context;
86
+ entryRef.current = entryKey;
87
+ headingRef.current = activeHeading;
88
+ }, [index, context, entryKey, activeHeading]);
40
89
 
41
- // Derived banner: read-only (no write grant) / no-key (no provider bound, SP-7)
42
- // come straight from props+provider; send-time errors layer on top.
43
- const staticBanner: Banner = !writable ? 'read-only' : provider === null ? 'no-key' : null;
44
- const banner: Banner = sendBanner || staticBanner;
90
+ // The loop's transcript, kept so follow-up questions replay the tool-use chain.
91
+ const transcriptRef = useRef<AgentMessage[]>([]);
45
92
 
46
93
  useEffect(() => {
47
94
  if (open) requestAnimationFrame(() => footRef.current?.focus());
48
95
  }, [open]);
96
+ // The panel's only trigger (the resting input) UNMOUNTS when it opens, and
97
+ // its onFocus IS the open action — returning focus to it would reopen the
98
+ // panel. So the close path returns focus to the resting line's SUBMIT
99
+ // control (R-IX-1): a real stop in the same surface that never reopens
100
+ // anything, so Escape never strands the keyboard on <body>. On mount the
101
+ // effect stays quiet (was-open gate) or the panel would open itself.
102
+ const restingGoRef = useRef<HTMLButtonElement>(null);
103
+ const wasOpenRef = useRef(false);
104
+ useEffect(() => {
105
+ if (wasOpenRef.current && !open) requestAnimationFrame(() => restingGoRef.current?.focus());
106
+ wasOpenRef.current = open;
107
+ }, [open]);
49
108
  useEffect(() => {
50
109
  bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight });
51
- }, [msgs, streaming]);
110
+ }, [rows, streaming]);
52
111
 
53
- async function send(text: string) {
54
- const q = text.trim();
112
+ async function ask(question: string) {
113
+ const q = question.trim();
55
114
  if (!q || streaming) return;
56
115
  setOpen(true);
57
116
  setDraft('');
58
117
  setResting('');
59
- const history = [...msgs, { role: 'user' as const, text: q }];
60
- setMsgs([...history, { role: 'assistant', text: '' }]);
118
+ setErrorToast(null);
119
+ setRows((prev) => [...prev, { kind: 'user', text: q }]);
61
120
  setStreaming(true);
62
- setSendBanner(null);
63
121
 
122
+ // G-GA-8: a provider without `features.tools` degrades to context-stuffing —
123
+ // the deixis block, an index summary, and the current entry body ride the
124
+ // prompt; the request carries ZERO tools.
125
+ const toolsSupported = providerState.status === 'configured' && providerState.provider.features.tools === true;
126
+ const chroot = getContentRoot();
127
+ const currentKey = entryRef.current;
128
+ const contextBlock = renderAgentContext({
129
+ ...contextRef.current,
130
+ entryPath: currentKey,
131
+ heading: headingRef.current || undefined,
132
+ });
133
+ let stuffedBody: string | undefined;
134
+ if (!toolsSupported) {
135
+ stuffedBody = await safeSources.read(currentKey).catch(() => undefined);
136
+ }
137
+ const system = buildSystemPrompt({
138
+ contextBlock,
139
+ toolsSupported,
140
+ ...(toolsSupported ? {} : { entryBody: stuffedBody, entryPath: currentKey, index: indexRef.current, chroot }),
141
+ });
142
+ const entryTool = createReadEntryTool(chroot);
143
+ const metaTool = createGroveMetadataTool(chroot, () => indexRef.current);
144
+ const execute = toolExecutor([entryTool, metaTool]);
145
+ const tools = toolsSupported ? groveAgentTools(chroot, () => indexRef.current) : [];
146
+
147
+ let acc = '';
148
+ const pushAssistant = (text: string) =>
149
+ setRows((prev) => {
150
+ const next = prev.slice();
151
+ if (next.length && next[next.length - 1].kind === 'assistant') next[next.length - 1] = { kind: 'assistant', text };
152
+ else next.push({ kind: 'assistant', text });
153
+ return next;
154
+ });
155
+ const controller = new AbortController();
156
+ abortRef.current = controller;
64
157
  try {
65
- const req = {
66
- messages: [
67
- {
68
- role: 'system' as const,
69
- content: [
70
- {
71
- type: 'text' as const,
72
- 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.`,
73
- },
74
- ],
158
+ const final = await runAgent({
159
+ client: createChatModelClient(),
160
+ tools,
161
+ execute,
162
+ system,
163
+ prompt: q,
164
+ history: transcriptRef.current,
165
+ maxTurns: 8,
166
+ signal: controller.signal,
167
+ events: {
168
+ onAssistantDelta: (t) => {
169
+ acc += t;
170
+ pushAssistant(acc);
171
+ },
172
+ onToolUse: (name, input) => {
173
+ acc = '';
174
+ setRows((prev) => {
175
+ const next = prev.slice();
176
+ if (next.length && next[next.length - 1].kind === 'assistant' && !next[next.length - 1].text) next.pop();
177
+ next.push({ kind: 'activity', text: toolActivityLine(name, input) });
178
+ return next;
179
+ });
75
180
  },
76
- ...history.map((m) => ({
77
- role: m.role,
78
- content: [{ type: 'text' as const, text: m.text }],
79
- })),
80
- ],
81
- modelHint: 'smart' as const,
82
- };
83
- let acc = '';
84
- for await (const d of chat(req)) {
85
- if (d.type === 'text-delta') {
86
- acc += d.text;
87
- setMsgs((prev) => {
88
- const next = prev.slice();
89
- next[next.length - 1] = { role: 'assistant', text: acc };
90
- return next;
91
- });
92
- }
181
+ },
182
+ });
183
+ const rendered = transcriptToRows(final);
184
+ // The real loop RESOLVES an aborted run with the partial transcript (a
185
+ // clean stop, never a thrown error) — so the abort check lives here too,
186
+ // not only in the catch: a stopped run keeps what streamed and appends a
187
+ // named Stopped row (R3-608).
188
+ if (controller.signal.aborted) {
189
+ setRows((prev) => [...prev, { kind: 'activity', text: 'Stopped' }]);
190
+ return;
191
+ }
192
+ if (!rendered.some((r) => r.kind === 'assistant')) throw new Error('empty');
193
+ transcriptRef.current = final.slice();
194
+ setRows(rendered);
195
+ } catch (e) {
196
+ // A stop is not an error (R3-608): the aborted run settles with a named
197
+ // "Stopped" row, and the transcript keeps what streamed so far.
198
+ if (controller.signal.aborted) {
199
+ setRows((prev) => [...prev, { kind: 'activity', text: 'Stopped' }]);
200
+ return;
93
201
  }
94
- if (!acc) throw new Error('empty');
95
- } catch (e: any) {
96
- const code = e?.code || e?.message;
97
- setSendBanner(code === 'auth-required' ? 'no-key' : code === 'forbidden' ? 'forbidden' : 'error');
98
- // Drop the empty assistant placeholder.
99
- setMsgs((prev) => (prev.length && prev[prev.length - 1].text === '' ? prev.slice(0, -1) : prev));
202
+ const code = (e as { code?: string })?.code || (e as Error).message;
203
+ setErrorToast(
204
+ code === 'auth-required'
205
+ ? 'no model key connected — add one in Settings'
206
+ : code === 'forbidden'
207
+ ? "this Grove wasn't granted chat — reading works as normal"
208
+ : 'the model or backend errored — try again in a moment',
209
+ );
210
+ setRows((prev) =>
211
+ prev.length && prev[prev.length - 1].kind === 'assistant' && !prev[prev.length - 1].text ? prev.slice(0, -1) : prev,
212
+ );
100
213
  } finally {
101
214
  setStreaming(false);
215
+ if (abortRef.current === controller) abortRef.current = null;
102
216
  }
103
217
  }
104
218
 
105
- const restingDisabled = banner === 'read-only';
106
-
107
219
  return (
108
220
  <div className="grove-agent">
109
- {/* Resting: the always-present thin input line (approach 1). */}
221
+ {/* Resting: the always-present thin input line. Q&A is never disabled by
222
+ read-only (R-GA-5) — only by the envelope genuinely lacking a provider. */}
110
223
  {!open && (
111
224
  <form
112
225
  className="ga-line"
113
226
  onSubmit={(e) => {
114
227
  e.preventDefault();
115
- if (restingDisabled) setOpen(true);
116
- else send(resting);
228
+ if (canAsk) void ask(resting);
229
+ else setOpen(true);
117
230
  }}
118
231
  >
119
232
  <span className="mk" />
120
233
  <input
121
234
  value={resting}
122
- placeholder={restingDisabled ? 'Ask Grove about this wiki…' : `Ask Grove to change "${entryTitle}"…`}
235
+ placeholder={`Ask Grove about “${entryTitle}”…`}
123
236
  onChange={(e) => setResting(e.target.value)}
124
237
  onFocus={() => setOpen(true)}
125
238
  aria-label="Ask Grove"
126
239
  />
127
- <button className="go" type="submit" aria-label="Ask Grove">
240
+ <button ref={restingGoRef} className="go" type="submit" aria-label="Ask Grove">
128
241
  <Icon name="send" />
129
242
  </button>
130
243
  </form>
@@ -134,16 +247,20 @@ export default function GroveAgent({ writable, entryKey, entryTitle }: { writabl
134
247
  {open && (
135
248
  <>
136
249
  <div className="ga-scrim" onClick={() => setOpen(false)} />
137
- <div className="ga-panel" data-detent={detent}>
250
+ <div
251
+ className="ga-panel"
252
+ data-detent={detent}
253
+ ref={dialogRef}
254
+ tabIndex={-1}
255
+ role="dialog"
256
+ aria-modal="true"
257
+ aria-label="Ask Grove"
258
+ >
138
259
  <div className="ga-panel-inner">
139
260
  <div className="ga-head">
140
261
  <span className="grip" onClick={() => setDetent((d) => (d === 'half' ? 'full' : 'half'))} />
141
262
  <span className="mk" />
142
263
  <span className="id">Grove</span>
143
- <span className="scope">
144
- <Icon name="shield" />
145
- scoped to this wiki
146
- </span>
147
264
  <span className="grow">
148
265
  <button className="ic" aria-label={detent === 'half' ? 'Expand' : 'Collapse'} onClick={() => setDetent((d) => (d === 'half' ? 'full' : 'half'))}>
149
266
  <Icon name={detent === 'half' ? 'chevron-down' : 'chevron-right'} />
@@ -154,19 +271,36 @@ export default function GroveAgent({ writable, entryKey, entryTitle }: { writabl
154
271
  </span>
155
272
  </div>
156
273
 
157
- {banner && <AgentBanner banner={banner} />}
274
+ {/* The reach card — the envelope, computed (R-GA-1). */}
275
+ <div className="ga-reach" role="list" aria-label="What the agent can do here">
276
+ {reachRows.map((r) => (
277
+ <div className={`ga-reach__row ga-reach__row--${r.state}`} role="listitem" key={r.key}>
278
+ <span className="ga-reach__mark" aria-hidden>
279
+ {r.state === 'ok' ? '✓' : r.state === 'blocked' ? '✗' : '·'}
280
+ </span>
281
+ <span className="ga-reach__label">{r.label}</span>
282
+ {r.cause && <span className="ga-reach__cause">{r.cause}</span>}
283
+ </div>
284
+ ))}
285
+ </div>
286
+ {showEgressDisclosure(providerState) && <p className="ga-egress">{EGRESS_DISCLOSURE}</p>}
287
+ {errorToast && (
288
+ <div className="ga-toast" role="status">
289
+ {errorToast}
290
+ </div>
291
+ )}
158
292
 
159
293
  <div className="ga-body" ref={bodyRef}>
160
- {msgs.length === 0 ? (
294
+ {rows.length === 0 ? (
161
295
  <>
162
296
  <div className="ga-explain">
163
- <b>Grove's agent</b> knows this wiki's entries, tags, and components. Ask it to add or
164
- change content in plain language — it proposes the edit, and the host confirms the write.
297
+ <b>Grove's agent</b> answers questions about this wiki — its entries, tags, and structure — from the
298
+ page you're on.
165
299
  </div>
166
- {!restingDisabled && (
300
+ {chips.length > 0 && (
167
301
  <div className="ga-chips">
168
- {CHIPS.map((c) => (
169
- <button key={c} className="ga-chip" onClick={() => send(c)}>
302
+ {chips.map((c) => (
303
+ <button key={c} className="ga-chip" onClick={() => void ask(c)}>
170
304
  {c}
171
305
  </button>
172
306
  ))}
@@ -174,20 +308,27 @@ export default function GroveAgent({ writable, entryKey, entryTitle }: { writabl
174
308
  )}
175
309
  </>
176
310
  ) : (
177
- msgs.map((m, i) => (
178
- <div key={i} className={`ga-msg ${m.role}`}>
179
- <span className={`ga-msg__av ${m.role === 'assistant' ? 'grove' : 'user'}`}>
180
- {m.role === 'user' ? 'you' : ''}
181
- </span>
182
- <div className="ga-msg__b">
183
- <div className="ga-msg__who">{m.role === 'assistant' ? 'Grove' : 'You'}</div>
184
- <div className="ga-msg__txt">
185
- {m.text}
186
- {streaming && i === msgs.length - 1 && m.role === 'assistant' ? <span className="ga-cursor" /> : null}
311
+ rows.map((r, i) =>
312
+ r.kind === 'activity' ? (
313
+ <div key={i} className="ga-activity">
314
+ <Icon name="file" />
315
+ {r.text}
316
+ </div>
317
+ ) : (
318
+ <div key={i} className={`ga-msg ${r.kind}`}>
319
+ <span className={`ga-msg__av ${r.kind === 'assistant' ? 'grove' : 'user'}`}>
320
+ {r.kind === 'assistant' ? '' : 'you'}
321
+ </span>
322
+ <div className="ga-msg__b">
323
+ <div className="ga-msg__who">{r.kind === 'assistant' ? 'Grove' : 'You'}</div>
324
+ <div className="ga-msg__txt">
325
+ {r.text}
326
+ {streaming && i === rows.length - 1 && r.kind === 'assistant' ? <span className="ga-cursor" /> : null}
327
+ </div>
187
328
  </div>
188
329
  </div>
189
- </div>
190
- ))
330
+ ),
331
+ )
191
332
  )}
192
333
  </div>
193
334
 
@@ -196,30 +337,42 @@ export default function GroveAgent({ writable, entryKey, entryTitle }: { writabl
196
337
  className="ga-foot__row"
197
338
  onSubmit={(e) => {
198
339
  e.preventDefault();
199
- send(draft);
340
+ void ask(draft);
200
341
  }}
201
342
  >
202
343
  <input
203
344
  ref={footRef}
204
345
  value={draft}
205
346
  placeholder="Ask Grove…"
206
- disabled={streaming || restingDisabled}
347
+ disabled={streaming || !canAsk}
207
348
  onChange={(e) => setDraft(e.target.value)}
208
349
  />
209
- <button className="go" type="submit" disabled={streaming || restingDisabled || !draft.trim()} aria-label="Send">
210
- <Icon name={streaming ? 'stop' : 'send'} />
350
+ {streaming ? (
351
+ <button
352
+ className="stop"
353
+ type="button"
354
+ aria-label="Stop"
355
+ title="Stop"
356
+ onClick={() => abortRef.current?.abort()}
357
+ >
358
+ <Icon name="x" />
359
+ </button>
360
+ ) : null}
361
+ <button className="go" type="submit" disabled={streaming || !canAsk || !draft.trim()} aria-label={streaming ? 'Answering…' : 'Send'} title={streaming ? 'Answering…' : 'Send'}>
362
+ <Icon name="send" />
211
363
  </button>
212
364
  </form>
213
365
  <div className="ga-foot__hand">
214
- <span>Grove's own agent · scoped to your grants</span>
215
- <a
216
- onClick={() => openEditor(entryKey)}
217
- role="button"
218
- tabIndex={0}
219
- >
366
+ <span>{EGRESS_DISCLOSURE}</span>
367
+ {editRefused && (
368
+ <span className="ga-edit-refused" role="status">
369
+ {EDIT_REFUSED_NOTICE}
370
+ </span>
371
+ )}
372
+ <button type="button" onClick={() => openEditor(entryKey)}>
220
373
  <Icon name="external" />
221
- Open in the workbench
222
- </a>
374
+ Open in the editor
375
+ </button>
223
376
  </div>
224
377
  </div>
225
378
  </div>
@@ -229,37 +382,3 @@ export default function GroveAgent({ writable, entryKey, entryTitle }: { writabl
229
382
  </div>
230
383
  );
231
384
  }
232
-
233
- // The no-key / forbidden / read-only / error states — calm explanations that hand
234
- // to the host secret flow rather than faking a key prompt.
235
- function AgentBanner({ banner }: { banner: Banner }) {
236
- const copy: Record<string, { t: string; d: React.ReactNode }> = {
237
- 'no-key': {
238
- t: 'Connect a key to use the agent.',
239
- d: 'Grove rides whichever model provider you connect in the host. Add a key to start asking.',
240
- },
241
- forbidden: {
242
- t: 'The agent isn’t available here.',
243
- d: "This Grove wasn't granted the chat capability. Reading still works as normal.",
244
- },
245
- 'read-only': {
246
- t: 'You’re viewing read-only.',
247
- d: 'The agent can explain this wiki, but changes need write access to this space.',
248
- },
249
- error: {
250
- t: 'The agent hit a snag.',
251
- d: 'The model or backend errored. Try again in a moment.',
252
- },
253
- };
254
- const c = copy[banner as string];
255
- if (!c) return null;
256
- return (
257
- <div className="ga-banner">
258
- <Icon name="alert" />
259
- <div>
260
- <div className="ga-banner__t">{c.t}</div>
261
- <div className="ga-banner__d">{c.d}</div>
262
- </div>
263
- </div>
264
- );
265
- }