@immediately-run/grove 0.1.5 → 0.1.7

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.
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @immediately-run/grove — the viewer kit for directory-as-content wikis
2
2
 
3
- > The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.1.5)
3
+ > The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.1.7)
4
4
 
5
5
  Grove is NOT a wiki engine: routing, MDX compilation, the frontmatter index, link
6
6
  spaces and heading anchors live in the sandbox + `@immediately-run/sdk`. What this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@immediately-run/grove",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "main": "src/main.tsx",
6
6
  "immediately.run": {
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@immediately-run/mdx-plugins": "0.7.1",
44
- "@immediately-run/sdk": "^0.67.0",
44
+ "@immediately-run/sdk": "^0.72.0",
45
45
  "react": "^19.2.5",
46
46
  "react-dom": "^19.2.5"
47
47
  },
@@ -87,7 +87,7 @@
87
87
  "llms.txt"
88
88
  ],
89
89
  "peerDependencies": {
90
- "@immediately-run/sdk": "^0.67.0",
90
+ "@immediately-run/sdk": "^0.72.0",
91
91
  "react": "^19.2.5",
92
92
  "react-dom": "^19.2.5"
93
93
  }
package/src/GroveApp.css CHANGED
@@ -2654,6 +2654,23 @@ button.grove-search__row {
2654
2654
  .ga-reach__row--neutral .ga-reach__mark {
2655
2655
  color: var(--ink-3);
2656
2656
  }
2657
+ .ga-reach__row--elsewhere .ga-reach__mark {
2658
+ /* A destination, not an approval and not a denial (R3-752): ink, not accent. */
2659
+ color: var(--ink);
2660
+ }
2661
+ /* The row's state as text, for the screen reader the glyph gives nothing (R3-752,
2662
+ R-IX-7): visually hidden, never removed from the tree. */
2663
+ .ga-reach__stateword {
2664
+ position: absolute;
2665
+ width: 1px;
2666
+ height: 1px;
2667
+ padding: 0;
2668
+ margin: -1px;
2669
+ overflow: hidden;
2670
+ clip: rect(0 0 0 0);
2671
+ white-space: nowrap;
2672
+ border: 0;
2673
+ }
2657
2674
  .ga-reach__label {
2658
2675
  color: var(--ink);
2659
2676
  font-weight: 500;
package/src/GroveWiki.tsx CHANGED
@@ -33,7 +33,7 @@ import { preferredPolarity } from './data/themes';
33
33
  import { useDirectoryListing } from './hooks/useDirectoryListing';
34
34
  import { useScrollReset } from './hooks/useScrollReset';
35
35
  import { useEditAffordance } from './hooks/useEditAffordance';
36
- import { getContentRoot } from './lib/contentRoot';
36
+ import { getContentRoot, isDispatched } from './lib/contentRoot';
37
37
  import type { RejectedComponent } from './lib/corpusComponents';
38
38
  import { GroveShellContext, OutletContext } from './lib/shell';
39
39
  import type { GroveShell, NavItem } from './lib/shell';
@@ -415,10 +415,29 @@ export default function GroveWiki({
415
415
  return (
416
416
  // R3-277b: declare the enclosing bundle for the platform's link-space consumers
417
417
  // (the shared resolver's bundle-anchored absolute + `$fs:` handling read this).
418
- // R3-482: the field keeps the deprecated `corpusRoot` spelling until grove's SDK
419
- // pin reaches a release whose WikiLink reads `bundleRoot` (sdk#171 / 0.68.1) —
420
- // stating only the new spelling now would silently un-anchor every absolute link.
421
- <LinkSpaceContext.Provider value={{ corpusRoot: getContentRoot() }}>
418
+ // R3-482: the SDK pin (^0.72.0, #75) reads `bundleRoot` new-then-old, so the new
419
+ // spelling is stated alone — the deprecated `corpusRoot` stays in the type for
420
+ // older consumers (mdx-plugins' forever-compat) but no longer here.
421
+ <LinkSpaceContext.Provider
422
+ value={{
423
+ bundleRoot: getContentRoot(),
424
+ // R3-184 S2 (PERSISTENCE_SPEC §8.3) — the `$fs:` clamp, on this render path.
425
+ // The clamp that is IN FORCE lives in `lib/content.ts` (`linkSpaceOpts`):
426
+ // `hrefTargetKey` is where a corpus document's link targets actually
427
+ // resolve (grove's own WikiLink override routes there), and it passes
428
+ // `bundleChrooted: isDispatched()` to the shared resolver (R3-319's
429
+ // chroot), so a dispatched corpus document's `$fs:/mnt/{hash}/…` cannot
430
+ // name a federated mount materialised beside it — the app-level mount
431
+ // point is not a corpus path — and the fork's `$fs:` stays
432
+ // mount-absolute. THIS field carries the same flag for the SDK's generic
433
+ // component consumers (WikiLink/MDXComponents): the pinned SDK 0.72.0
434
+ // does not forward it, so it is decided-but-not-consumed until a
435
+ // forwarding release is published and pinned — the booked follow-up,
436
+ // stated here rather than blurred (§8.3's dated note:
437
+ // built-but-not-in-force is a materially different state from done).
438
+ bundleChrooted: isDispatched(),
439
+ }}
440
+ >
422
441
  {/* R3-174: the bundle scope CONTENT reads — sibling to the link space, not a
423
442
  replacement for it. The two answer different questions: `LinkSpaceContext` tells
424
443
  the platform's link resolver where absolute hrefs are anchored; `BundleContext`
@@ -7,7 +7,7 @@
7
7
  // the hooks — the component reads what the host would have said.
8
8
  import { describe, it, expect, vi, beforeAll } from 'vitest';
9
9
  import { act } from 'react';
10
- import { createRoot, type Root } from 'react-dom/client';
10
+ import { createRoot } from 'react-dom/client';
11
11
 
12
12
  // `useHeadings` scans the DOM (no .grove-prose in this harness ⇒ no headings — fine).
13
13
  // `fs` is read only through safeSources on the stuffing path, never in these cases.
@@ -21,77 +21,11 @@ vi.mock('@immediately-run/sdk', async (importOriginal) => ({
21
21
  runAgent: (opts: unknown) => runAgentMock(opts),
22
22
  }));
23
23
 
24
- // An EMITTING stub transport: push channels subscribe per type and tests emit
25
- // host→app messages through it (the same wire the real host speaks).
26
- // The transport contract (`hostTransport.ts`): `onMessage(handler)` receives EVERY
27
- // host→app message; the SDK's addListener filters by `msg.type` itself.
28
- type Handler = (msg: Record<string, unknown>) => void;
29
- const handlers = new Set<Handler>();
30
- const emit = (msg: Record<string, unknown>): void => {
31
- for (const h of handlers) h(msg);
32
- };
33
- // Controllable per-case (the edit-refusal probe rejects through it); the
34
- // default resolves like a permissive host.
35
- const protocolRequest = vi.fn(async () => ({}));
36
- (globalThis as { __immediatelyRun__?: unknown }).__immediatelyRun__ = {
37
- transport: {
38
- sendMessage: vi.fn(),
39
- protocolRequest,
40
- onMessage: (handler: Handler) => {
41
- handlers.add(handler);
42
- return { dispose: () => handlers.delete(handler) };
43
- },
44
- },
45
- };
46
-
47
- import type { GroveShell } from '../lib/shell';
48
- import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
49
- const { default: GroveAgent } = await import('./GroveAgent');
50
- const { GroveShellContext } = await import('../lib/shell');
51
-
52
- const NAV = {
53
- mode: 'github',
54
- namespace: 'immediately-run',
55
- provider: 'github',
56
- repository: 'docs',
57
- ref: 'main',
58
- sandboxPath: '/app',
59
- hash: '',
60
- search: '',
61
- };
62
-
63
- const shell: GroveShell = { openEditor: vi.fn() } as unknown as GroveShell;
64
-
65
- async function renderAgent(props: { writable: boolean }): Promise<{ root: Root; container: HTMLElement }> {
66
- const container = document.createElement('div');
67
- document.body.appendChild(container);
68
- const root = createRoot(container);
69
- await act(async () => {
70
- root.render(
71
- <GroveShellContext.Provider value={shell}>
72
- <TinkerableContext.Provider
73
- value={{ outerHref: '', navigationState: NAV, routingSpec: {} as never, filesMetadata: {} }}
74
- >
75
- <GroveAgent {...props} entryKey="/app/content/wiki/security.mdx" entryTitle="Security" />
76
- </TinkerableContext.Provider>
77
- </GroveShellContext.Provider>,
78
- );
79
- });
80
- return { root, container };
81
- }
82
-
83
- const openPanel = async (container: HTMLElement): Promise<void> => {
84
- await act(async () => {
85
- (container.querySelector('.ga-line input') as HTMLInputElement)?.focus();
86
- });
87
- };
88
-
89
- /** Drive host→app pushes the way the host would: after the app subscribed. */
90
- const push = async (msg: Record<string, unknown>): Promise<void> => {
91
- await act(async () => {
92
- emit(msg);
93
- });
94
- };
24
+ // The transport stub, the renderer and the push driver live in
25
+ // `src/test/groveAgentHarness.tsx` — shared rather than copied, because
26
+ // `GroveAgent.unanswered.test.tsx` needs the same harness in a module registry where the
27
+ // catalog has never been answered (grove#75 round 3).
28
+ const { renderAgent, openPanel, push, protocolRequest } = await import('../../test/groveAgentHarness');
95
29
 
96
30
  // NOTE: no per-test handler reset. The SDK's push channels are module-global and
97
31
  // subscribe to the transport exactly once (the `started` flag in `createPushChannel`),
@@ -101,15 +35,29 @@ const push = async (msg: Record<string, unknown>): Promise<void> => {
101
35
  describe('G-GA-1 — no unbacked capability claims in the DOM', () => {
102
36
  it('writable=false + no provider: no write-flavored chip, no phantom-write copy', async () => {
103
37
  const { container } = await renderAgent({ writable: false });
104
- await push({ type: 'llm-provider', provider: null }); // answered: not-configured
105
- await push({ type: 'api-catalog', methods: [] }); // nothing granted
38
+ // The host generation this models matters, because the two pushes below only make
39
+ // sense together on ONE of them. R-LLM-2 says a frame lacking `llm:chat` is answered
40
+ // `{ provider: null }` — the SAME payload as a granted frame with no key — so on a
41
+ // host that predates R3-688's `ungranted` mark this pair (null provider, empty
42
+ // catalog) is exactly what an ungranted fork sees, and it is indistinguishable at the
43
+ // provider channel from keyless-but-granted. That indistinguishability IS R3-688.
44
+ // A 0.72.0 host would instead mark the answer, which the R3-688 case below covers.
45
+ await push({ type: 'llm-provider', provider: null }); // pre-mark host: null either way
46
+ await push({ type: 'api-catalog', methods: [] }); // nothing granted — the deciding fact
106
47
  await openPanel(container);
107
48
  const text = container.textContent ?? '';
108
49
  expect(text).not.toMatch(/add an entry|fix broken links|reorganize the sidebar|add a timeline/i);
109
50
  expect(text).not.toMatch(/proposes the edit|host confirms the write/i);
110
51
  expect(container.querySelectorAll('.ga-chip').length).toBe(0); // no ✓ rows ⇒ no chips
111
- // And the honest causes ARE there:
112
- expect(text).toContain('no model key connected');
52
+ // And the honest causes ARE there. R3-688 CHANGED which one this case gets, and the
53
+ // change is the point of the item. On a pre-mark host the provider channel cannot
54
+ // tell keyless from ungranted, so the CATALOG is the only fact that discriminates —
55
+ // and it says not granted. The old order asked not-configured first and therefore
56
+ // told a user who may well have a key to go add one, which cannot unblock them.
57
+ // The key cause is still rendered for a frame the catalog says IS granted
58
+ // (`reachCard.test.ts` — "not-configured names the KEY cause").
59
+ expect(text).toContain("wasn't granted chat");
60
+ expect(text).not.toContain('no model key connected');
113
61
  expect(text).toContain('you’re a reader here');
114
62
  });
115
63
  });
@@ -135,6 +83,22 @@ describe('G-GA-2 / R-GA-5 — read-only never blocks Q&A', () => {
135
83
  expect(container.textContent).not.toMatch(/Suggest an edit/i); // no write chip while read-only
136
84
  });
137
85
 
86
+ it('keyless but GRANTED renders the KEY cause — the case the catalog decides the other way', async () => {
87
+ // The counterpart the suite lost when the G-GA-1 case above changed verdict, and the
88
+ // one that proves the reorder did not simply make the grant cause win everywhere.
89
+ // Same provider payload as that case — `{ provider: null }`, which R-LLM-2 gives both
90
+ // a keyless frame and (on a pre-mark host) an ungranted one — but the catalog says
91
+ // `llm:chat` IS granted. So the only actionable fact is the missing key, and that is
92
+ // what the card must say.
93
+ const { container } = await renderAgent({ writable: true });
94
+ await push({ type: 'llm-provider', provider: null });
95
+ await push({ type: 'api-catalog', methods: [{ name: 'llm:chat', capability: 'llm:chat', stream: true }] });
96
+ await openPanel(container);
97
+ const text = container.textContent ?? '';
98
+ expect(text).toContain('no model key connected — add one in Settings');
99
+ expect(text).not.toContain("wasn't granted chat");
100
+ });
101
+
138
102
  it('an ungranted fork renders the DISTINCT forbidden cause, never connect-a-key copy', async () => {
139
103
  const { container } = await renderAgent({ writable: true });
140
104
  await push({
@@ -154,6 +118,19 @@ describe('G-GA-2 / R-GA-5 — read-only never blocks Q&A', () => {
154
118
  expect(input.disabled).toBe(true); // cannot ask — but reading works, and the card says why
155
119
  });
156
120
 
121
+ it('R3-688 — the host-marked grantless answer renders the consent cause without any provider', async () => {
122
+ // The ungranted fork is answered {provider:null, ungranted:true} — never the
123
+ // provider — so before the mark this fork could only render the KEY copy. The
124
+ // mark is the host's grant decision; the card repeats it, never invents it.
125
+ const { container } = await renderAgent({ writable: true });
126
+ await push({ type: 'llm-provider', provider: null, ungranted: true });
127
+ await push({ type: 'api-catalog', methods: [] });
128
+ await openPanel(container);
129
+ const text = container.textContent ?? '';
130
+ expect(text).toContain("this Grove wasn't granted chat — reading works as normal");
131
+ expect(text).not.toContain('add one in Settings'); // the KEY copy — never conflated (G-GA-10)
132
+ });
133
+
157
134
  it('R-GA-6 — the egress disclosure shows whenever a provider is bound', async () => {
158
135
  const { container } = await renderAgent({ writable: false });
159
136
  await push({
@@ -168,6 +145,79 @@ describe('G-GA-2 / R-GA-5 — read-only never blocks Q&A', () => {
168
145
  await openPanel(container);
169
146
  expect(container.textContent).toContain('answers come from your connected model provider');
170
147
  });
148
+
149
+ // R3-752 — the apply row is a DESTINATION: `→` on the row, no ✗ anywhere on the
150
+ // card, and every row's state is text as well as glyph.
151
+ it('R3-752 — read-only + configured granted provider: the apply row renders → elsewhere, never ✗, with hidden state words', async () => {
152
+ const { container } = await renderAgent({ writable: false });
153
+ await push({
154
+ type: 'llm-provider',
155
+ provider: {
156
+ providerId: 'llm.chat.anthropic',
157
+ hostVouched: true,
158
+ features: { vision: false, tools: true, jsonMode: true, reasoning: false, maxContextTokens: 100000 },
159
+ },
160
+ });
161
+ await push({ type: 'api-catalog', methods: [{ name: 'llm:chat', capability: 'llm:chat', stream: true }] });
162
+ await openPanel(container);
163
+ const card = container.querySelector('.ga-reach')!;
164
+ const applyRow = card.querySelector('.ga-reach__row--elsewhere')!;
165
+ expect(applyRow.textContent).toContain('Apply changes');
166
+ expect(applyRow.textContent).toContain('→ in the editor or workbench, where you confirm them');
167
+ // A destination is never rendered as a denial: the card's only ✗ is the draft
168
+ // row's (read-only), the apply row's mark is the arrow.
169
+ expect(applyRow.querySelector('.ga-reach__mark')!.textContent).toBe('→');
170
+ expect(card.textContent).not.toContain('changes open in the editor'); // the old cause copy is gone
171
+ // State is text, never glyph alone: the hidden words are in the tree.
172
+ const words = [...card.querySelectorAll('.ga-reach__stateword')].map((n) => n.textContent);
173
+ expect(words).toContain('available');
174
+ expect(words).toContain('unavailable');
175
+ expect(words).toContain('opens elsewhere');
176
+ // The substrate is context, stated, with the git-indeterminate trust line under
177
+ // the card (the SDK's fail-closed default basis).
178
+ expect(card.textContent).toContain('Reads its own entries');
179
+ expect(card.parentElement!.textContent).toContain('anyone who can push to this repo can change what the agent reads');
180
+ });
181
+
182
+ it('R3-752 — a grant flip is ANNOUNCED, not only re-rendered (R-IX-7): the standing live region tracks the card', async () => {
183
+ const { container } = await renderAgent({ writable: false });
184
+ await push({
185
+ type: 'llm-provider',
186
+ provider: {
187
+ providerId: 'llm.chat.anthropic',
188
+ hostVouched: true,
189
+ features: { vision: false, tools: true, jsonMode: true, reasoning: false, maxContextTokens: 100000 },
190
+ },
191
+ });
192
+ await push({ type: 'api-catalog', methods: [] }); // key, but NOT granted chat
193
+ await openPanel(container);
194
+ const live = container.querySelector('[role="status"].ga-reach__stateword')!;
195
+ expect(live.getAttribute('aria-live') ?? 'polite').toBe('polite'); // role=status is polite by default
196
+ expect(live.textContent).toContain('unavailable'); // the blocked Q&A row, in words
197
+
198
+ // Grant chat: the flip must change the announcement — the Q&A segment goes
199
+ // unavailable → available (the draft row stays honestly unavailable; read-only).
200
+ await push({ type: 'api-catalog', methods: [{ name: 'llm:chat', capability: 'llm:chat', stream: true }] });
201
+ expect(live.textContent).toContain('Answer questions about this wiki — available');
202
+ expect(live.textContent).not.toContain('Answer questions about this wiki — unavailable');
203
+ });
204
+
205
+ it('R3-752 — a configured provider WITHOUT tools keeps Q&A ✓ and shows the degrade qualifier, not silence', async () => {
206
+ const { container } = await renderAgent({ writable: false });
207
+ await push({
208
+ type: 'llm-provider',
209
+ provider: {
210
+ providerId: 'llm.chat.anthropic',
211
+ hostVouched: true,
212
+ features: { vision: false, tools: false, jsonMode: true, reasoning: false, maxContextTokens: 100000 },
213
+ },
214
+ });
215
+ await push({ type: 'api-catalog', methods: [{ name: 'llm:chat', capability: 'llm:chat', stream: true }] });
216
+ await openPanel(container);
217
+ const answerRow = container.querySelector('.ga-reach__row--ok')!;
218
+ expect(answerRow.textContent).toContain('Answer questions about this wiki');
219
+ expect(answerRow.textContent).toContain('reads a summary of this wiki, not entries on demand');
220
+ });
171
221
  });
172
222
 
173
223
  describe('R3-608 — the composer stops the run; a refusal surfaces, a cancel does not', () => {
@@ -278,21 +328,10 @@ describe('R3-608 — the composer stops the run; a refusal surfaces, a cancel do
278
328
  });
279
329
 
280
330
  it('the agent panel renders the refusal text where the affordance is offered', async () => {
281
- const host = document.createElement('div');
282
- document.body.appendChild(host);
283
- const root = createRoot(host);
284
- const refusingShell: GroveShell = { openEditor: vi.fn(), editRefused: true } as unknown as GroveShell;
285
- await act(async () => {
286
- root.render(
287
- <GroveShellContext.Provider value={refusingShell}>
288
- <TinkerableContext.Provider
289
- value={{ outerHref: '', navigationState: NAV, routingSpec: {} as never, filesMetadata: {} }}
290
- >
291
- <GroveAgent writable={false} entryKey="/app/content/wiki/security.mdx" entryTitle="Security" />
292
- </TinkerableContext.Provider>
293
- </GroveShellContext.Provider>,
294
- );
295
- });
331
+ const { root, container: host } = await renderAgent(
332
+ { writable: false },
333
+ { shell: { editRefused: true } as Partial<import('../lib/shell').GroveShell> },
334
+ );
296
335
  await openPanel(host);
297
336
  expect(host.textContent).toContain('Could not open the editor — the host refused');
298
337
  await act(async () => {
@@ -15,18 +15,22 @@ import { useOverlayFocusDismiss } from '../hooks/useOverlayFocusDismiss';
15
15
  import { getContentRoot } from '../lib/contentRoot';
16
16
  import { createReadEntryTool, createGroveMetadataTool, groveAgentTools, toolExecutor } from '../lib/agentTools';
17
17
  import { buildSystemPrompt } from '../lib/agentPrompt';
18
- import { computeReachRows, reachChips, showEgressDisclosure, EGRESS_DISCLOSURE } from '../lib/reachCard';
18
+ import { computeReachRows, reachChips, sourceTrustLine, stateWord, showEgressDisclosure, EGRESS_DISCLOSURE } from '../lib/reachCard';
19
+ import { getCorpusMountId } from '../lib/contentRoot';
19
20
  import { transcriptToRows, toolActivityLine, type AgentRow } from '../lib/agentTranscript';
21
+ import { useCatalogAnswered } from '../hooks/useCatalogAnswered';
20
22
  import { safeSources } from '../lib/safeSources';
21
23
  import Icon from './Icon';
22
24
 
23
25
  // `.grove-agent` — Grove's own embedded agent (GROVE_AGENT_SPEC).
24
26
  //
25
27
  // 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
28
+ // the expanded header is computed from the provider four-state (R3-688 added the
29
+ // host-marked `ungranted` — the distinct not-granted cause), the `llm:chat`
30
+ // grant (the grant-filtered catalog), mount writability, the corpus packaging,
31
+ // tools support, and source trust — never hand-written copy. The loop rides the
32
+ // workbench's seam — SDK `runAgent` over the host `llm.chat` slot — and its two
33
+ // tools are the mount-chrooted `read_entry` and
30
34
  // the index query (R-GA-2). The widget never writes (R-GA-3): every change is a
31
35
  // hand-off to the editor / workbench. Read-only never blocks Q&A (R-GA-5). When a
32
36
  // provider is bound the egress line is shown unconditionally (R-GA-6). Every
@@ -66,12 +70,40 @@ export default function GroveAgent({
66
70
  // catalog iff this app holds the consent — an ungranted fork reads a DISTINCT
67
71
  // cause from a user without a key (G-GA-10).
68
72
  const chatGranted = catalog.some((m) => m.name === 'llm:chat');
73
+ // Distinguishes "granted nothing" from "has not replied yet" — `catalog` alone cannot,
74
+ // because an empty catalog is a legitimate answer. See the hook for how it is derived.
75
+ const catalogAnswered = useCatalogAnswered();
69
76
  const context = useAgentContext({ entryPath: entryKey, entryTitle, heading: activeHeading || undefined });
77
+ // G-GA-8: a provider without `features.tools` degrades to context-stuffing.
78
+ // Computed ONCE at render scope — the reach card's Q&A qualifier (R3-752) and the
79
+ // ask() path below read the same fact, never two computations of it.
80
+ const toolsSupported = providerState.status === 'configured' && providerState.provider.features.tools === true;
70
81
  const reachRows = useMemo(
71
- () => computeReachRows({ providerState, chatGranted, writable, sourceShared: context.sourceShared }),
72
- [providerState, chatGranted, writable, context.sourceShared],
82
+ () =>
83
+ computeReachRows({
84
+ providerState,
85
+ chatGranted,
86
+ catalogAnswered,
87
+ writable,
88
+ sourceShared: context.sourceShared,
89
+ mountId: getCorpusMountId(),
90
+ toolsSupported,
91
+ }),
92
+ [providerState, chatGranted, catalogAnswered, writable, context.sourceShared, toolsSupported],
73
93
  );
74
94
  const chips = useMemo(() => reachChips(reachRows), [reachRows]);
95
+ const trustLine = useMemo(
96
+ () => sourceTrustLine(context.sourceShared, context.sourceSharedBasis),
97
+ [context.sourceShared, context.sourceSharedBasis],
98
+ );
99
+ // R-IX-7 (R3-752): a state flip is ANNOUNCED, not only re-rendered — one standing
100
+ // polite live region whose text is the card's computed summary. A grant flip
101
+ // changes the text, which is what a status region announces; the visually hidden
102
+ // per-row words are static text and announce nothing.
103
+ const reachAnnouncement = useMemo(
104
+ () => `What the agent can do here: ${reachRows.map((r) => `${r.label} — ${stateWord(r.state)}`).join('; ')}`,
105
+ [reachRows],
106
+ );
75
107
  const canAsk = providerState.status === 'configured' && chatGranted;
76
108
 
77
109
  // Read at CALL time (a scan may land, the reader may navigate) — refs kept fresh
@@ -121,8 +153,8 @@ export default function GroveAgent({
121
153
 
122
154
  // G-GA-8: a provider without `features.tools` degrades to context-stuffing —
123
155
  // 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;
156
+ // prompt; the request carries ZERO tools. The same hoisted fact the reach
157
+ // card's Q&A qualifier reads (R3-752) — computed once at render scope.
126
158
  const chroot = getContentRoot();
127
159
  const currentKey = entryRef.current;
128
160
  const contextBlock = renderAgentContext({
@@ -271,18 +303,28 @@ export default function GroveAgent({
271
303
  </span>
272
304
  </div>
273
305
 
274
- {/* The reach card — the envelope, computed (R-GA-1). */}
306
+ {/* The reach card — the envelope, computed (R-GA-1). State is text as
307
+ well as glyph (R3-752): the hidden word beside the mark keeps the
308
+ card from reading as four identical lines to a screen reader. */}
275
309
  <div className="ga-reach" role="list" aria-label="What the agent can do here">
276
310
  {reachRows.map((r) => (
277
311
  <div className={`ga-reach__row ga-reach__row--${r.state}`} role="listitem" key={r.key}>
278
312
  <span className="ga-reach__mark" aria-hidden>
279
- {r.state === 'ok' ? '✓' : r.state === 'blocked' ? '✗' : '·'}
313
+ {r.state === 'ok' ? '✓' : r.state === 'blocked' ? '✗' : r.state === 'elsewhere' ? '→' : '·'}
280
314
  </span>
315
+ <span className="ga-reach__stateword">{stateWord(r.state)}</span>
281
316
  <span className="ga-reach__label">{r.label}</span>
282
317
  {r.cause && <span className="ga-reach__cause">{r.cause}</span>}
318
+ {r.destination && <span className="ga-reach__cause">{`→ ${r.destination}`}</span>}
283
319
  </div>
284
320
  ))}
285
321
  </div>
322
+ {trustLine && <p className="ga-egress">{trustLine}</p>}
323
+ {/* The standing live region (R-IX-7): the computed card summary, so a
324
+ state flip is announced politely and not only re-rendered. */}
325
+ <div className="ga-reach__stateword" role="status">
326
+ {reachAnnouncement}
327
+ </div>
286
328
  {showEgressDisclosure(providerState) && <p className="ga-egress">{EGRESS_DISCLOSURE}</p>}
287
329
  {errorToast && (
288
330
  <div className="ga-toast" role="status">
@@ -0,0 +1,45 @@
1
+ // @vitest-environment jsdom
2
+ // ONE case, in its own file, because it needs a module registry where the catalog has
3
+ // never been answered.
4
+ //
5
+ // `useCatalogAnswered`'s flag is module-global and never resets — correct for an app, in
6
+ // which the host answers once per realm, but it means any test that has already pushed an
7
+ // `api-catalog` poisons this one. Every case in `GroveAgent.test.tsx` pushes one. Vitest
8
+ // isolates by file, so a separate file IS the isolation; the harness is shared rather
9
+ // than copied (`src/test/groveAgentHarness.tsx`).
10
+ //
11
+ // Why this case exists at all: round 3 showed the hook was not load-bearing for any
12
+ // component test — replacing `useCatalogAnswered()` with the literal `true`, i.e. exactly
13
+ // the behaviour the hook was written to replace, left all 506 tests green. The unit arm
14
+ // (`reachCard.test.ts`) and the hook itself were covered; the wire between them was not.
15
+ // That is round 1's blocking finding recurring one level up.
16
+ import { describe, it, expect, vi } from 'vitest';
17
+
18
+ // `useHeadings` scans the DOM; `fs` is read only on the stuffing path, never here.
19
+ vi.mock('fs', () => ({ default: { promises: { readFile: vi.fn(async () => '') } } }));
20
+ vi.mock('@immediately-run/sdk', async (importOriginal) => ({
21
+ ...(await importOriginal<typeof import('@immediately-run/sdk')>()),
22
+ runAgent: vi.fn(),
23
+ }));
24
+
25
+ const { renderAgent, openPanel, push } = await import('../../test/groveAgentHarness');
26
+
27
+ describe('R3-688 — before the catalog answers, the Q&A row names no cause', () => {
28
+ it('renders neutral: not a guessed consent state, not a guessed key state, no chips', async () => {
29
+ // No catalog push at all. The provider HAS answered `{ provider: null }`, which on a
30
+ // pre-mark host is what both a keyless frame and an ungranted fork receive — so the
31
+ // grant is genuinely unknown and the card must say nothing rather than pick one.
32
+ const { container } = await renderAgent({ writable: true });
33
+ await push({ type: 'llm-provider', provider: null });
34
+ await openPanel(container);
35
+ const text = container.textContent ?? '';
36
+
37
+ expect(text).toContain('Answer questions about this wiki');
38
+ expect(text).not.toContain("wasn't granted chat");
39
+ expect(text).not.toContain('no model key connected');
40
+ // No Q&A chips. Not "no chips at all": `writable: true` makes the Draft row ✓ and it
41
+ // contributes its own, which is correct and has nothing to do with the catalog.
42
+ const chips = [...container.querySelectorAll('.ga-chip')].map((c) => c.textContent ?? '');
43
+ expect(chips.some((c) => /summarize|tagged security/i.test(c))).toBe(false);
44
+ });
45
+ });
@@ -0,0 +1,59 @@
1
+ // @vitest-environment jsdom
2
+ // The LATE-IMPORT case, in its own file because it is the one ordering the sibling suite
3
+ // cannot produce: the host answers BEFORE this module is evaluated.
4
+ //
5
+ // grove is published as a pinned library (`src/lib.ts`), so "imported before any render"
6
+ // is an assumption about one embedding, not a guarantee. If the subscribe-time call is
7
+ // discarded with a blanket reset, a host that already answered is invisible for the
8
+ // realm's life — and because the host does not push again, nothing corrects it.
9
+ //
10
+ // This file exists because round 3's own fix was untested: replacing
11
+ // `answered = catalog.length > 0` with `answered = false` — i.e. the blanket reset the
12
+ // fix replaced — left the whole suite green. A fix with no failing case is a guess.
13
+ import { describe, it, expect, vi } from 'vitest';
14
+
15
+ type Handler = (msg: Record<string, unknown>) => void;
16
+ const handlers = new Set<Handler>();
17
+ const emit = (msg: Record<string, unknown>): void => {
18
+ for (const h of handlers) h(msg);
19
+ };
20
+
21
+ (globalThis as { __immediatelyRun__?: unknown }).__immediatelyRun__ = {
22
+ transport: {
23
+ sendMessage: vi.fn(),
24
+ protocolRequest: vi.fn(async () => ({})),
25
+ onMessage: (handler: Handler) => {
26
+ handlers.add(handler);
27
+ return { dispose: () => handlers.delete(handler) };
28
+ },
29
+ },
30
+ };
31
+
32
+ describe('useCatalogAnswered, imported AFTER the host answered', () => {
33
+ it('reads a non-empty catalog at subscribe as proof the answer landed', async () => {
34
+ // Start and answer the channel through the SDK directly, with the hook module not yet
35
+ // imported — the ordering a late-mounting library consumer produces.
36
+ const { getCatalog } = await import('@immediately-run/sdk');
37
+ getCatalog();
38
+ emit({ type: 'api-catalog', methods: [{ name: 'llm:chat', capability: 'llm:chat', stream: true }] });
39
+
40
+ // NOW import. The listener's synchronous subscribe-time call carries the answered
41
+ // catalog; a blanket reset would throw it away and the flag would be false forever.
42
+ const { useCatalogAnswered } = await import('./useCatalogAnswered');
43
+
44
+ const { act } = await import('react');
45
+ const { createRoot } = await import('react-dom/client');
46
+ const seen: boolean[] = [];
47
+ const Probe = () => {
48
+ seen.push(useCatalogAnswered());
49
+ return null;
50
+ };
51
+ const root = createRoot(document.createElement('div'));
52
+ await act(async () => {
53
+ root.render(<Probe />);
54
+ });
55
+
56
+ expect(seen[seen.length - 1]).toBe(true);
57
+ await act(async () => root.unmount());
58
+ });
59
+ });