@immediately-run/grove 0.1.5 → 0.1.8

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.
@@ -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, UNGRANTED_CHAT_CAUSE, READING_WORKS_NORMAL } 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({
@@ -204,8 +236,10 @@ export default function GroveAgent({
204
236
  code === 'auth-required'
205
237
  ? 'no model key connected — add one in Settings'
206
238
  : code === 'forbidden'
207
- ? "this Grove wasn't granted chat — reading works as normal"
208
- : 'the model or backend errored — try again in a moment',
239
+ ? UNGRANTED_CHAT_CAUSE
240
+ : code === 'cancelled'
241
+ ? `chat didn't start — ${READING_WORKS_NORMAL}; you can try again anytime`
242
+ : 'the model or backend errored — try again in a moment',
209
243
  );
210
244
  setRows((prev) =>
211
245
  prev.length && prev[prev.length - 1].kind === 'assistant' && !prev[prev.length - 1].text ? prev.slice(0, -1) : prev,
@@ -271,18 +305,44 @@ export default function GroveAgent({
271
305
  </span>
272
306
  </div>
273
307
 
274
- {/* The reach card — the envelope, computed (R-GA-1). */}
308
+ {/* The reach card — the envelope, computed (R-GA-1). State is text as
309
+ well as glyph (R3-752): the hidden word beside the mark keeps the
310
+ card from reading as four identical lines to a screen reader. */}
275
311
  <div className="ga-reach" role="list" aria-label="What the agent can do here">
276
312
  {reachRows.map((r) => (
277
313
  <div className={`ga-reach__row ga-reach__row--${r.state}`} role="listitem" key={r.key}>
278
314
  <span className="ga-reach__mark" aria-hidden>
279
- {r.state === 'ok' ? '✓' : r.state === 'blocked' ? '✗' : '·'}
315
+ {r.state === 'ok' ? '✓' : r.state === 'blocked' ? '✗' : r.state === 'elsewhere' ? '→' : '·'}
280
316
  </span>
317
+ <span className="ga-reach__stateword">{stateWord(r.state)}</span>
281
318
  <span className="ga-reach__label">{r.label}</span>
282
319
  {r.cause && <span className="ga-reach__cause">{r.cause}</span>}
320
+ {r.destination && <span className="ga-reach__cause">{`→ ${r.destination}`}</span>}
321
+ {r.action === 'enable-chat' && (
322
+ // R3-790: the earning affordance. Its click invokes ask() — the
323
+ // ungranted llm:chat invoke fires the HOST's consent dialog on this
324
+ // same activation (site-main#612), Allow mints + lifts, and the
325
+ // same ask proceeds to stream. A decline answers 'cancelled' (the
326
+ // catch below) and the row stays honestly ✗.
327
+ <button
328
+ type="button"
329
+ className="ga-reach__enable"
330
+ onClick={() =>
331
+ void ask(draft.trim() || 'Answer in one sentence: what can you tell me about this wiki?')
332
+ }
333
+ >
334
+ Enable chat
335
+ </button>
336
+ )}
283
337
  </div>
284
338
  ))}
285
339
  </div>
340
+ {trustLine && <p className="ga-egress">{trustLine}</p>}
341
+ {/* The standing live region (R-IX-7): the computed card summary, so a
342
+ state flip is announced politely and not only re-rendered. */}
343
+ <div className="ga-reach__stateword" role="status">
344
+ {reachAnnouncement}
345
+ </div>
286
346
  {showEgressDisclosure(providerState) && <p className="ga-egress">{EGRESS_DISCLOSURE}</p>}
287
347
  {errorToast && (
288
348
  <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
+ });
@@ -3,6 +3,7 @@ import { Include, Link } from '@immediately-run/sdk';
3
3
  import { useShell, EDIT_REFUSED_NOTICE } from '../lib/shell';
4
4
  import { keyToHref, keyToRepoRel } from '../lib/content';
5
5
  import { crumb } from '../lib/wiki';
6
+ import { useHeadingFragmentUrl } from '../hooks/useHeadingFragmentUrl';
6
7
  import DirectoryView from './DirectoryView';
7
8
  import EntryHeader from './EntryHeader';
8
9
  import SafeEntryBody from './SafeEntryBody';
@@ -22,6 +23,11 @@ export default function PageView() {
22
23
  const { entryKey, includePath, layout, showRails, mins, missing, suggestion, writable, openEditor, editBusy, editRefused, editHint, vw, safe, directory } =
23
24
  useShell();
24
25
 
26
+ // The reading view owns the headings, so it owns the outgoing half of deep
27
+ // linking: same-page heading navigation writes the fragment into the host's
28
+ // address bar (`useHeadingFragmentUrl` for the whole story).
29
+ useHeadingFragmentUrl();
30
+
25
31
  // A folder URL. `checking` renders nothing rather than the 404: the readdir that
26
32
  // decides between them is one RPC away, and a 404 that appears and then turns into a
27
33
  // listing reads as a broken link that healed itself.
@@ -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
+ });
@@ -0,0 +1,89 @@
1
+ // @vitest-environment jsdom
2
+ // `useCatalogAnswered` — rendered in a real React tree, driven through the SDK's REAL
3
+ // catalog push channel against an emitting stub transport. The whole point of the hook is
4
+ // a timing property of that channel, so a mock of either would assert nothing.
5
+ //
6
+ // The property under test: the push channel has no value-equality check, so a host answer
7
+ // of `[]` — identical to the initial value — still notifies. That second notification is
8
+ // the signal. A test that only pushed a NON-empty catalog would pass against a naive
9
+ // `catalog.length > 0` implementation and prove nothing, which is why the empty-answer
10
+ // case is the one that carries this file.
11
+ import { describe, it, expect, vi, beforeAll } from 'vitest';
12
+ import { act } from 'react';
13
+ import { createRoot } from 'react-dom/client';
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
+ // Installed BEFORE the SDK is imported: the channel resolves its transport lazily, and
22
+ // the hook module subscribes at import time.
23
+ (globalThis as { __immediatelyRun__?: unknown }).__immediatelyRun__ = {
24
+ transport: {
25
+ sendMessage: vi.fn(),
26
+ protocolRequest: vi.fn(async () => ({})),
27
+ onMessage: (handler: Handler) => {
28
+ handlers.add(handler);
29
+ return { dispose: () => handlers.delete(handler) };
30
+ },
31
+ },
32
+ };
33
+
34
+ let useCatalogAnswered: typeof import('./useCatalogAnswered').useCatalogAnswered;
35
+ let getCatalog: typeof import('@immediately-run/sdk').getCatalog;
36
+
37
+ beforeAll(async () => {
38
+ ({ useCatalogAnswered } = await import('./useCatalogAnswered'));
39
+ ({ getCatalog } = await import('@immediately-run/sdk'));
40
+ });
41
+
42
+ /** Render a probe recording the hook's value on every render. */
43
+ const renderProbe = async () => {
44
+ const seen: boolean[] = [];
45
+ const Probe = () => {
46
+ seen.push(useCatalogAnswered());
47
+ return null;
48
+ };
49
+ const root = createRoot(document.createElement('div'));
50
+ await act(async () => {
51
+ root.render(<Probe />);
52
+ });
53
+ return { seen, unmount: () => act(async () => root.unmount()) };
54
+ };
55
+
56
+ describe('useCatalogAnswered', () => {
57
+ it('is false before the host answers — the subscribe-time call is not an answer', async () => {
58
+ // `onCatalogChange` invokes its listener once, SYNCHRONOUSLY, with the current value
59
+ // when you subscribe. If that counted, the flag would be true from module load and
60
+ // the reach card would read an unanswered catalog as "not granted" — the exact defect
61
+ // this hook exists to prevent.
62
+ getCatalog(); // start the channel the way the app does
63
+ const { seen, unmount } = await renderProbe();
64
+ expect(seen[seen.length - 1]).toBe(false);
65
+ await unmount();
66
+ });
67
+
68
+ it('an EMPTY catalog answer flips it, and RE-RENDERS — an empty answer is still an answer', async () => {
69
+ const { seen, unmount } = await renderProbe();
70
+ expect(seen[seen.length - 1]).toBe(false);
71
+ const before = seen.length;
72
+
73
+ await act(async () => {
74
+ emit({ type: 'api-catalog', methods: [] });
75
+ });
76
+
77
+ // Both halves matter: a hook that returned the flag without subscribing would show
78
+ // the right value only on the NEXT unrelated render.
79
+ expect(seen.length).toBeGreaterThan(before);
80
+ expect(seen[seen.length - 1]).toBe(true);
81
+ await unmount();
82
+ });
83
+
84
+ it('stays true for a probe mounted after the answer', async () => {
85
+ const { seen, unmount } = await renderProbe();
86
+ expect(seen[seen.length - 1]).toBe(true);
87
+ await unmount();
88
+ });
89
+ });
@@ -0,0 +1,73 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { onCatalogChange } from '@immediately-run/sdk';
3
+
4
+ // Has the host actually ANSWERED the grant-filtered method catalog?
5
+ //
6
+ // `useCatalog()` starts `[]` and an empty catalog is a legitimate answer (a frame granted
7
+ // nothing), so the value alone cannot distinguish "granted nothing" from "has not replied
8
+ // yet". The reach card needs that distinction: `!chatGranted` is true in both cases, and
9
+ // reading it as "not granted" before the host has spoken claims a consent state about a
10
+ // frame that may well hold the grant (grove#75 round 1).
11
+ //
12
+ // It is derivable here with no SDK change, because the push channel has **no
13
+ // value-equality check**: every host push notifies subscribers, including one whose value
14
+ // equals the initial `[]`. So a notification after the subscribe means the host replied.
15
+ //
16
+ // THE SUBSCRIBE-TIME CALL. `onCatalogChange` invokes the listener once, SYNCHRONOUSLY,
17
+ // with the current value before returning. That call is the subscribe, not an answer — but
18
+ // it is not worthless either, and an earlier version of this file threw it away with a
19
+ // blanket reset. A NON-EMPTY catalog at subscribe can only have come from the host, so it
20
+ // is proof the answer already landed. Reading it that way is what makes this correct when
21
+ // the module is evaluated LATE: grove is also published as a pinned library
22
+ // (`src/lib.ts`), so "imported before any render" is an assumption about one embedding,
23
+ // not a guarantee (grove#75 round 3).
24
+ //
25
+ // RESIDUALS, stated rather than implied:
26
+ // - a host that answers `[]` BEFORE this module is evaluated is indistinguishable from
27
+ // one that has not answered, and leaves the flag false for the realm's life. The Q&A
28
+ // row then stays neutral rather than naming a cause — the same honest "we have not been
29
+ // told" that the provider channel's `unknown` already renders, but it will not
30
+ // self-correct, because the host does not push again;
31
+ // - a host that never answers at all leaves it false forever, for the same reason;
32
+ // - importing this module is what STARTS the catalog channel and sends
33
+ // `REQUEST_API_CATALOG`. That used to be the first `useCatalog()` render. The channel
34
+ // starts at most once and the poll is idempotent, but the timing moved.
35
+ let answered = false;
36
+ let subscribing = true;
37
+ const waiters = new Set<() => void>();
38
+
39
+ onCatalogChange((catalog) => {
40
+ if (subscribing) {
41
+ // Synchronous, at subscribe: the current value, not an answer — except that a
42
+ // non-empty catalog could only have come from the host.
43
+ answered = catalog.length > 0;
44
+ return;
45
+ }
46
+ if (answered) return;
47
+ answered = true;
48
+ for (const w of waiters) w();
49
+ });
50
+ subscribing = false;
51
+
52
+ const subscribe = (onStoreChange: () => void): (() => void) => {
53
+ waiters.add(onStoreChange);
54
+ return () => {
55
+ waiters.delete(onStoreChange);
56
+ };
57
+ };
58
+ const getSnapshot = (): boolean => answered;
59
+
60
+ /**
61
+ * Whether the host has answered the method catalog. Re-renders when it flips.
62
+ *
63
+ * `useSyncExternalStore` rather than `useState` + `useEffect`, deliberately: the flag is
64
+ * read during render and written outside React, and a passive effect leaves a window
65
+ * between the two. An answer landing in that window used to be lost — the effect saw the
66
+ * flag already set, returned early without registering a waiter, and the component
67
+ * rendered a stale `false` with nothing left to wake it (grove#75 round 3, reproduced).
68
+ * `useSyncExternalStore` re-reads the snapshot after subscribing and re-renders if it
69
+ * moved, which is exactly that hole.
70
+ */
71
+ export function useCatalogAnswered(): boolean {
72
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
73
+ }
@@ -30,14 +30,11 @@ export interface EditAffordance {
30
30
  /**
31
31
  * What a save actually does, so the affordance can say so.
32
32
  *
33
- * **A stated residual (2026-08-27, R3-266).** The CoW overlay and the contribute (PR)
34
- * flow are anchored on the APP's repo. Under a fork the app and the corpus are one
35
- * repo, so "save" and "propose a change" are one story. Under dispatch they are two:
36
- * the write lands in the corpus mount correctly, and *"open a PR against the content
37
- * repo"* has no wired target. That is real remaining work — and it is not a reason to
38
- * withhold editing, because a viewer that saves but cannot yet propose is strictly
39
- * better than one that refuses to save. It IS a reason not to imply otherwise, so the
40
- * chrome labels the dispatched case for what it is.
33
+ * Under a fork the app and the corpus are one repo, so "save" and "propose a
34
+ * change" are one story. Under dispatch they are two mounts — and since
35
+ * R3-643's host half (site-main #576) both are wired: the write lands in the
36
+ * corpus mount, and the contribute flow forks/branches/opens the PR against
37
+ * the CONTENT repo (the viewer's repo receives nothing).
41
38
  */
42
39
  editHint: string;
43
40
  }
@@ -92,7 +89,7 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
92
89
  );
93
90
 
94
91
  const editHint = corpus.dispatched
95
- ? 'Edits save to the mounted content. Proposing a change back to its repository is not wired yet.'
92
+ ? 'Edits save to the mounted content, and can be proposed back to its repository as a PR.'
96
93
  : 'Edit this entry';
97
94
 
98
95
  return { writable, busy, refused, openEditor, editHint };
@@ -0,0 +1,146 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import { act } from 'react';
4
+ import { createRoot, type Root } from 'react-dom/client';
5
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
6
+ import { navigate } from '@immediately-run/sdk';
7
+ import { useHeadingFragmentUrl } from './useHeadingFragmentUrl';
8
+
9
+ vi.mock('@immediately-run/sdk', () => ({ navigate: vi.fn() }));
10
+
11
+ const NAV = {
12
+ outerHref: 'https://immediately.run/present/github/acme/wiki/main',
13
+ navigationState: {
14
+ mode: 'present',
15
+ provider: 'github',
16
+ namespace: 'acme',
17
+ repository: 'wiki',
18
+ ref: 'main',
19
+ sandboxPath: '/content/guide.mdx',
20
+ hash: '',
21
+ search: '',
22
+ },
23
+ routingSpec: {},
24
+ filesMetadata: {},
25
+ };
26
+
27
+ /** The hook must be INSIDE the provider — in the app the host provides above
28
+ * `<GroveApp>`; here the probe plays that role and an inner component owns the hook. */
29
+ function Inner() {
30
+ useHeadingFragmentUrl();
31
+ return (
32
+ <main className="grove-prose">
33
+ <h2 id="sec-2">Two</h2>
34
+ <h3 id="sec-2-1">Two point one</h3>
35
+ <a href="#sec-2" data-testid="toc-entry">Two</a>
36
+ <a href="https://immediately.run/present/github/acme/wiki/main/files/guide.mdx#sec-2-1" data-testid="permalink">
37
+ link
38
+ </a>
39
+ <a href="https://example.com/elsewhere#sec-2" data-testid="external">ext</a>
40
+ <a href="#sec-99" data-testid="dead">dead</a>
41
+ </main>
42
+ );
43
+ }
44
+
45
+ function Probe({ outerHref = NAV.outerHref, hash = '' }: { outerHref?: string; hash?: string }) {
46
+ return (
47
+ <TinkerableContext.Provider value={{ ...NAV, outerHref, navigationState: { ...NAV.navigationState, hash } } as never}>
48
+ <Inner />
49
+ </TinkerableContext.Provider>
50
+ );
51
+ }
52
+
53
+ function clickOn(testId: string, init?: MouseEventInit) {
54
+ const el = document.querySelector<HTMLElement>(`[data-testid="${testId}"]`)!;
55
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0, ...init }));
56
+ }
57
+
58
+ function mount(props: Parameters<typeof Probe>[0]) {
59
+ const host = document.createElement('div');
60
+ document.body.appendChild(host);
61
+ const root: Root = createRoot(host);
62
+ act(() => root.render(<Probe {...props} />));
63
+ return () => {
64
+ act(() => root.unmount());
65
+ host.remove();
66
+ };
67
+ }
68
+
69
+ // A same-page fragment resolves against the current navigation state — the reader's
70
+ // own path, hash replaced (no `/files/` join: the target is not an absolute path).
71
+ const SAME_PAGE = 'https://immediately.run/present/github/acme/wiki/main/content/guide.mdx#sec-2';
72
+
73
+ describe('useHeadingFragmentUrl', () => {
74
+ beforeEach(() => {
75
+ (navigate as unknown as ReturnType<typeof vi.fn>).mockClear();
76
+ });
77
+ afterEach(() => {
78
+ document.body.innerHTML = '';
79
+ });
80
+
81
+ it('navigates to the fragment when a TOC-style #anchor is clicked', () => {
82
+ const un = mount({});
83
+ act(() => clickOn('toc-entry'));
84
+ expect(navigate).toHaveBeenCalledWith(SAME_PAGE);
85
+ un();
86
+ });
87
+
88
+ it('navigates to the CURRENT path when a permalink is clicked — only its fragment is taken', () => {
89
+ const un = mount({});
90
+ act(() => clickOn('permalink'));
91
+ // Not the permalink's own URL: the fragment rides the path the reader is on.
92
+ expect(navigate).toHaveBeenCalledWith(
93
+ 'https://immediately.run/present/github/acme/wiki/main/content/guide.mdx#sec-2-1',
94
+ );
95
+ un();
96
+ });
97
+
98
+ it('navigates when the heading element itself is clicked', () => {
99
+ const un = mount({});
100
+ act(() => {
101
+ document.getElementById('sec-2')!.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }));
102
+ });
103
+ expect(navigate).toHaveBeenCalledWith(SAME_PAGE);
104
+ un();
105
+ });
106
+
107
+ it('leaves modifier clicks to the browser (open-in-new-tab, copy-link)', () => {
108
+ const un = mount({});
109
+ act(() => clickOn('toc-entry', { metaKey: true }));
110
+ act(() => clickOn('toc-entry', { ctrlKey: true }));
111
+ act(() => clickOn('toc-entry', { button: 1 }));
112
+ expect(navigate).not.toHaveBeenCalled();
113
+ un();
114
+ });
115
+
116
+ it('leaves external links alone, even ones that carry a fragment', () => {
117
+ const un = mount({});
118
+ act(() => clickOn('external'));
119
+ expect(navigate).not.toHaveBeenCalled();
120
+ un();
121
+ });
122
+
123
+ it('does not push a history entry for the fragment the URL already shows', () => {
124
+ const un = mount({ hash: 'sec-2' });
125
+ act(() => clickOn('toc-entry'));
126
+ expect(navigate).not.toHaveBeenCalled();
127
+ un();
128
+ });
129
+
130
+ it('does not navigate for a fragment that names nothing on the page', () => {
131
+ const un = mount({});
132
+ act(() => clickOn('dead'));
133
+ expect(navigate).not.toHaveBeenCalled();
134
+ un();
135
+ });
136
+
137
+ it('off-host, writes the fragment locally instead of messaging a host that is not there', () => {
138
+ const replaceState = vi.spyOn(history, 'replaceState').mockImplementation(() => {});
139
+ const un = mount({ outerHref: '' });
140
+ act(() => clickOn('toc-entry'));
141
+ expect(navigate).not.toHaveBeenCalled();
142
+ expect(replaceState).toHaveBeenCalledWith(null, '', '#sec-2');
143
+ replaceState.mockRestore();
144
+ un();
145
+ });
146
+ });