@immediately-run/grove 0.1.7 → 0.1.10

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.7)
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.10)
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.7",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "main": "src/main.tsx",
6
6
  "immediately.run": {
@@ -28,7 +28,7 @@
28
28
  "lint": "eslint . --max-warnings 0",
29
29
  "test": "vitest run",
30
30
  "preview": "vite preview",
31
- "verify": "npm run check:llms && npm run check:engine-api:selftest && npm run check:engine-api && npm run check:manifest && npm run check:deps && npm run check:published && npm run check:theme-contrast && npm run check:theme-token-only && npm run lint && npm run build && npm run test",
31
+ "verify": "npm run check:llms && npm run check:engine-api:selftest && npm run check:engine-api && npm run check:manifest && npm run check:deps && npm run check:published && npm run check:theme-contrast && npm run check:theme-token-only && npm run check:lock-version && npm run lint && npm run build && npm run test",
32
32
  "check:theme-contrast": "node scripts/check-theme-contrast.mjs --self-test && node scripts/check-theme-contrast.mjs",
33
33
  "check:theme-token-only": "node scripts/check-theme-token-only.mjs --self-test && node scripts/check-theme-token-only.mjs",
34
34
  "check:manifest": "node scripts/check-manifest.mjs --self-test && node scripts/check-manifest.mjs",
@@ -37,7 +37,8 @@
37
37
  "check:engine-api": "node scripts/check-engine-api.mjs",
38
38
  "check:engine-api:selftest": "node scripts/check-engine-api.mjs --self-test",
39
39
  "gen:llms": "node scripts/gen-llms.mjs",
40
- "check:llms": "node scripts/gen-llms.mjs --check"
40
+ "check:llms": "node scripts/gen-llms.mjs --check",
41
+ "check:lock-version": "node scripts/check-lock-version.mjs --self-test && node scripts/check-lock-version.mjs"
41
42
  },
42
43
  "dependencies": {
43
44
  "@immediately-run/mdx-plugins": "0.7.1",
package/src/GroveApp.css CHANGED
@@ -2686,6 +2686,26 @@ button.grove-search__row {
2686
2686
  .ga-reach__cause {
2687
2687
  color: var(--ink-2);
2688
2688
  }
2689
+ /* R3-790 — the ✗ chat row's earning affordance: a small token'd control inside
2690
+ the reach card (the .ga-chip vocabulary, scaled to the card's 12.5px context). */
2691
+ .ga-reach__enable {
2692
+ font-family: var(--sans);
2693
+ font-weight: 600;
2694
+ font-size: 12px;
2695
+ color: var(--ink);
2696
+ background: var(--panel-2);
2697
+ border: 1px solid var(--line-2);
2698
+ border-radius: var(--r-pill);
2699
+ padding: 3px 10px;
2700
+ margin-left: 8px;
2701
+ cursor: pointer;
2702
+ transition: .14s;
2703
+ }
2704
+ .ga-reach__enable:hover,
2705
+ .ga-reach__enable:focus-visible {
2706
+ border-color: var(--accent-2);
2707
+ color: var(--accent-2);
2708
+ }
2689
2709
  .ga-egress {
2690
2710
  margin: 0;
2691
2711
  padding: 8px 16px;
@@ -367,3 +367,155 @@ describe('R3-608 round 2 — the panel returns focus to its (unmounted) trigger'
367
367
  expect(container.querySelector('.ga-panel')).toBeNull(); // focus did not reopen
368
368
  });
369
369
  });
370
+
371
+ // ── R3-790 — the ungranted chat row's earning affordance ──────────────────────
372
+
373
+ describe("R3-790 — the ✗ chat row offers its earning affordance (click invokes; the host gesture mints)", () => {
374
+ beforeAll(() => {
375
+ HTMLElement.prototype.scrollTo = () => undefined as unknown as void;
376
+ });
377
+
378
+ it('renders Enable chat on the ungranted row — the host-marked forbidden cause', async () => {
379
+ const { container } = await renderAgent({ writable: false });
380
+ // The cold mark — no prior configured push: the host strips the provider from
381
+ // every answer to an ungranted frame (the R3-688 test's shape).
382
+ await push({ type: 'llm-provider', provider: null, ungranted: true });
383
+ await push({ type: 'api-catalog', methods: [] }); // answered: nothing granted
384
+ await openPanel(container);
385
+ const enable = container.querySelector('.ga-reach__enable');
386
+ expect(enable).toBeTruthy(); // the affordance exists on the ✗ row
387
+ expect((enable as HTMLElement).textContent).toBe('Enable chat');
388
+ // …and the row itself still tells the honest cause.
389
+ expect(container.querySelector('.ga-reach__row--blocked')?.textContent).toContain(
390
+ "this Grove wasn't granted chat",
391
+ );
392
+ });
393
+
394
+ it('renders NO affordance when the provider is not configured (that row points at Settings)', async () => {
395
+ const { container } = await renderAgent({ writable: false });
396
+ await push({ type: 'llm-provider', provider: null }); // not-configured, not ungranted
397
+ await push({ type: 'api-catalog', methods: [] });
398
+ await openPanel(container);
399
+ expect(container.querySelector('.ga-reach__enable')).toBeNull();
400
+ });
401
+
402
+ it("a click invokes; a DECLINED dialog ('cancelled') keeps the honest row and says so", async () => {
403
+ runAgentMock.mockClear();
404
+ runAgentMock.mockImplementationOnce(() => {
405
+ const e = new Error('declined') as Error & { code?: string };
406
+ e.code = 'cancelled';
407
+ throw e;
408
+ });
409
+ const { container } = await renderAgent({ writable: false });
410
+ // The cold mark — no prior configured push: the host strips the provider from
411
+ // every answer to an ungranted frame (the R3-688 test's shape).
412
+ await push({ type: 'llm-provider', provider: null, ungranted: true });
413
+ await push({ type: 'api-catalog', methods: [] });
414
+ await openPanel(container);
415
+ await act(async () => {
416
+ (container.querySelector('.ga-reach__enable') as HTMLButtonElement).click();
417
+ });
418
+ // The invoke RAN (runAgent was called once — the gesture's trigger)…
419
+ expect(runAgentMock).toHaveBeenCalledTimes(1);
420
+ // …the decline surfaced honestly…
421
+ await act(async () => {
422
+ await new Promise((r) => setTimeout(r, 16));
423
+ });
424
+ expect(container.querySelector('.ga-toast')?.textContent).toContain("chat didn't start — reading works as normal; you can try again anytime");
425
+ // …and the row still reads the forbidden cause with the affordance for the next click.
426
+ expect(container.querySelector('.ga-reach__row--blocked')?.textContent).toContain(
427
+ "this Grove wasn't granted chat",
428
+ );
429
+ expect(container.querySelector('.ga-reach__enable')).toBeTruthy();
430
+ });
431
+
432
+ it('a click invokes; a still-FORBIDDEN answer surfaces the refusal toast (pre-gesture hosts)', async () => {
433
+ runAgentMock.mockImplementationOnce(() => {
434
+ const e = new Error('forbidden') as Error & { code?: string };
435
+ e.code = 'forbidden';
436
+ throw e;
437
+ });
438
+ const { container } = await renderAgent({ writable: false });
439
+ // The cold mark — no prior configured push: the host strips the provider from
440
+ // every answer to an ungranted frame (the R3-688 test's shape).
441
+ await push({ type: 'llm-provider', provider: null, ungranted: true });
442
+ await push({ type: 'api-catalog', methods: [] });
443
+ await openPanel(container);
444
+ await act(async () => {
445
+ (container.querySelector('.ga-reach__enable') as HTMLButtonElement).click();
446
+ });
447
+ await act(async () => {
448
+ await new Promise((r) => setTimeout(r, 16));
449
+ });
450
+ expect(container.querySelector('.ga-toast')?.textContent).toContain("this Grove wasn't granted chat");
451
+ });
452
+ });
453
+
454
+ // ── R3-790 round 1 — the GRANTED leg: the click's invoke proceeds when the grant lands ──
455
+
456
+ describe("R3-790 — the granted leg (click → invoke → the grant lands → the ask proceeds, the row flips)", () => {
457
+ beforeAll(() => {
458
+ HTMLElement.prototype.scrollTo = () => undefined as unknown as void;
459
+ });
460
+ const configured = {
461
+ type: 'llm-provider',
462
+ provider: {
463
+ providerId: 'llm.chat.anthropic',
464
+ hostVouched: true,
465
+ features: { vision: false, tools: true, jsonMode: true, reasoning: false, maxContextTokens: 100000 },
466
+ },
467
+ } as const;
468
+ const grantedCatalog = {
469
+ type: 'api-catalog',
470
+ methods: [{ name: 'llm:chat', capability: 'llm:chat', stream: true }],
471
+ } as const;
472
+
473
+ it("the affordance's click drives the invoke; the granted catalog flips the row ✓ and the input enables", async () => {
474
+ runAgentMock.mockClear();
475
+ runAgentMock.mockImplementationOnce(async () => [
476
+ {
477
+ role: 'user',
478
+ content: [{ type: 'text', text: 'Answer in one sentence: what can you tell me about this wiki?' }],
479
+ },
480
+ {
481
+ role: 'assistant',
482
+ content: [{ type: 'text', text: 'This wiki is a small corpus about enabling chat.' }],
483
+ },
484
+ ]);
485
+ const { container } = await renderAgent({ writable: false });
486
+ await push({ type: 'llm-provider', provider: null, ungranted: true });
487
+ await push({ type: 'api-catalog', methods: [] });
488
+ await openPanel(container);
489
+ // Before: the honest ✗ row + the affordance.
490
+ expect(container.querySelector('.ga-reach__row--blocked')?.textContent).toContain(
491
+ "this Grove wasn't granted chat",
492
+ );
493
+ await act(async () => {
494
+ (container.querySelector('.ga-reach__enable') as HTMLButtonElement).click();
495
+ });
496
+ // The invoke RAN (the gesture's trigger).
497
+ expect(runAgentMock).toHaveBeenCalledTimes(1);
498
+ // The grant lands (the host's mint + lift answers the channels): push the
499
+ // granted catalog + the configured provider — the envelope recomputes.
500
+ await push(grantedCatalog);
501
+ await push(configured);
502
+ await act(async () => {
503
+ await new Promise((r) => setTimeout(r, 16));
504
+ });
505
+ await act(async () => {
506
+ await new Promise((r) => setTimeout(r, 16));
507
+ });
508
+ // The ask PROCEEDED: the assistant answer is in the body…
509
+ expect(container.querySelector('.ga-body')?.textContent).toContain('enabling chat');
510
+ // …the ANSWER row flipped ✓ (the read-only draft row stays honestly ✗ — that is
511
+ // its own row, not this one)…
512
+ const answerRow = [...container.querySelectorAll('.ga-reach__row')].find((r) =>
513
+ r.textContent?.includes('Answer questions about this wiki'),
514
+ );
515
+ expect(answerRow?.className).not.toContain('blocked');
516
+ expect(container.querySelector('.ga-reach__enable')).toBeNull();
517
+ // …and the composer input is ENABLED (canAsk true).
518
+ const input = container.querySelector('.ga-foot input') as HTMLInputElement;
519
+ expect(input?.disabled).toBe(false);
520
+ });
521
+ });
@@ -15,7 +15,7 @@ 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, sourceTrustLine, stateWord, 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
19
  import { getCorpusMountId } from '../lib/contentRoot';
20
20
  import { transcriptToRows, toolActivityLine, type AgentRow } from '../lib/agentTranscript';
21
21
  import { useCatalogAnswered } from '../hooks/useCatalogAnswered';
@@ -236,8 +236,10 @@ export default function GroveAgent({
236
236
  code === 'auth-required'
237
237
  ? 'no model key connected — add one in Settings'
238
238
  : code === 'forbidden'
239
- ? "this Grove wasn't granted chat — reading works as normal"
240
- : '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',
241
243
  );
242
244
  setRows((prev) =>
243
245
  prev.length && prev[prev.length - 1].kind === 'assistant' && !prev[prev.length - 1].text ? prev.slice(0, -1) : prev,
@@ -316,6 +318,22 @@ export default function GroveAgent({
316
318
  <span className="ga-reach__label">{r.label}</span>
317
319
  {r.cause && <span className="ga-reach__cause">{r.cause}</span>}
318
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
+ )}
319
337
  </div>
320
338
  ))}
321
339
  </div>
@@ -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.
@@ -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
+ });
@@ -0,0 +1,98 @@
1
+ import { useCallback, useEffect } from 'react';
2
+ import { useContext } from 'react';
3
+ import { navigate } from '@immediately-run/sdk';
4
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
5
+ import { constructOuterUrl } from '@immediately-run/sdk/urlUtils';
6
+ import { fragmentOf } from '../lib/fragment';
7
+
8
+ /**
9
+ * Reflect same-page heading navigation in the ADDRESS BAR.
10
+ *
11
+ * The deep-linking pair has exactly one half so far: the host forwards the outer URL's
12
+ * `#fragment` in (R3-249, Capability C §13.5) and `<ScrollToFragment>` lands it — but
13
+ * nothing ever WRITES a fragment, so a reader who follows the contents rail, clicks a
14
+ * heading permalink or lands on a section has a URL bar that still names no section.
15
+ * Copying the address gives someone else the top of the page.
16
+ *
17
+ * This hook is the outgoing half. A delegated `click` listener on the document covers
18
+ * every way a heading is reached without each surface wiring its own handler:
19
+ *
20
+ * - the contents rail's `<a href="#id">` (grove's `<TableOfContents>` smooth-scrolls
21
+ * itself; the URL simply never heard about it);
22
+ * - the SDK's `<HeadingAnchor>` permalinks — absolute host URLs whose own pathname
23
+ * (a `/files/…` subpath) must NOT be navigated to, so only the FRAGMENT is taken and
24
+ * the target is rebuilt on the reader's current path;
25
+ * - a click on a heading element with an id (any depth).
26
+ *
27
+ * `navigate()` is the sanctioned app→host URL channel: an in-prefix `urlchange` whose
28
+ * target carries a hash is pushed onto the browser's address bar without a reload, and
29
+ * the echoed navigation sets `navigationState.hash`, which is what `<ScrollToFragment>`
30
+ * re-runs on — the platform's own loop completes the landing. Modifier clicks are the
31
+ * browser's (open-in-new-tab and copy-link are what the permalink's absolute href is
32
+ * FOR); a fragment naming nothing on the page is ignored rather than pushed; and a
33
+ * fragment equal to the one already in the URL does not stack another history entry.
34
+ *
35
+ * Off-host (`vite dev`, an empty `outerHref`) there is no host to tell, so the fragment
36
+ * is written to the frame's own URL with `history.replaceState` — a reload then lands
37
+ * where the reader was, and no history is spent.
38
+ */
39
+ export function useHeadingFragmentUrl(): void {
40
+ // Deep import per <ScrollToFragment>'s precedent: the context is not on the SDK's
41
+ // index surface, and this app reads it for `outerHref`/`navigationState` only.
42
+ const ctx = useContext(TinkerableContext) as {
43
+ outerHref?: string;
44
+ navigationState?: { hash?: string };
45
+ };
46
+
47
+ const sync = useCallback(
48
+ (fragment: string) => {
49
+ const navigationState = ctx?.navigationState;
50
+ if (!ctx?.outerHref || !navigationState) {
51
+ try {
52
+ history.replaceState(null, '', `#${fragment}`);
53
+ } catch {
54
+ // a history the browser refuses is dev noise, not an error
55
+ }
56
+ return;
57
+ }
58
+ navigate(constructOuterUrl(ctx.outerHref, `#${fragment}`, navigationState as never));
59
+ },
60
+ [ctx],
61
+ );
62
+
63
+ useEffect(() => {
64
+ const onClick = (event: MouseEvent) => {
65
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
66
+ const fragment = clickedFragment(event.target as HTMLElement | null, ctx?.outerHref ?? '');
67
+ if (!fragment) return;
68
+ // Already shown (locator-glued dev hashes included — `fragmentOf` takes the
69
+ // leading component): a second push would only stack a history entry.
70
+ if (fragmentOf(ctx?.navigationState?.hash) === fragment) return;
71
+ if (!document.getElementById(fragment) && !document.querySelector(`[data-slug="${fragment}"]`)) return;
72
+ sync(fragment);
73
+ };
74
+ document.addEventListener('click', onClick);
75
+ return () => document.removeEventListener('click', onClick);
76
+ }, [sync, ctx]);
77
+ }
78
+
79
+ /** The fragment this click addresses, or null when it is not heading navigation. */
80
+ function clickedFragment(element: HTMLElement | null, outerHref: string): string | null {
81
+ const anchor = element?.closest('a');
82
+ if (anchor) {
83
+ const href = anchor.getAttribute('href') ?? '';
84
+ if (href.startsWith('#')) return fragmentOf(href);
85
+ try {
86
+ const url = new URL(href, window.location.href);
87
+ // Absolute hrefs: only the app's OWN permalink URLs count. Their origin is the
88
+ // outer origin — the sandbox frame's `location.origin` is different, so compare
89
+ // against the outer href, never against the frame.
90
+ const ownOrigin = outerHref ? new URL(outerHref).origin : window.location.origin;
91
+ if (!url.hash || url.origin !== ownOrigin) return null;
92
+ return fragmentOf(url.hash);
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+ return element?.closest('h1[id], h2[id], h3[id], h4[id]')?.id ?? null;
98
+ }
@@ -510,3 +510,31 @@ describe("R-GA-6 — the egress disclosure is unconditional when a provider is b
510
510
  expect(showEgressDisclosure({ status: "not-configured" })).toBe(false);
511
511
  });
512
512
  });
513
+
514
+
515
+ // ── R3-790 — the earning affordance's arms ────────────────────────────────────
516
+
517
+ describe('R3-790 — the ✗ chat row carries the earning affordance on its arms', () => {
518
+ const configured: ChatProviderState = {
519
+ status: 'configured',
520
+ provider: { providerId: 'llm.chat.anthropic', hostVouched: true, features: { vision: false, tools: true, jsonMode: true, reasoning: false, maxContextTokens: 100000 } },
521
+ };
522
+ const base = { writable: true, sourceShared: false, catalogAnswered: true, mountId: null };
523
+
524
+ it('the host-marked ungranted arm carries it', () => {
525
+ const rows = computeReachRows({ ...base, providerState: { status: 'ungranted' } as ChatProviderState, chatGranted: false, toolsSupported: true });
526
+ expect(rows.find((r) => r.key === 'answer')?.action).toBe('enable-chat');
527
+ });
528
+
529
+ it("the belt arm carries it when the provider RESOLVES (configured, catalog answered, grant missing)", () => {
530
+ const rows = computeReachRows({ ...base, providerState: configured, chatGranted: false, toolsSupported: true });
531
+ expect(rows.find((r) => r.key === 'answer')?.action).toBe('enable-chat');
532
+ });
533
+
534
+ it('no arm carries it when the catalog is unanswered, or the provider is keyless', () => {
535
+ const neutral = computeReachRows({ ...base, catalogAnswered: false, providerState: configured, chatGranted: false, toolsSupported: true });
536
+ expect(neutral.find((r) => r.key === 'answer')?.action).toBeUndefined();
537
+ const keyless = computeReachRows({ ...base, providerState: { status: 'not-configured' } as ChatProviderState, chatGranted: false, toolsSupported: true });
538
+ expect(keyless.find((r) => r.key === 'answer')?.action).toBeUndefined();
539
+ });
540
+ });
@@ -39,11 +39,24 @@ export function stateWord(state: ReachRow['state']): string {
39
39
  *
40
40
  * `state: 'elsewhere'` is the apply row: the outcome happens at another surface, which is
41
41
  * where to go — never a ✗. */
42
+ /** R3-790 — the one spelling of the ungranted-chat cause: the row renders it and
43
+ * the composer's refusal toast composes from it, so a reword cannot drift the
44
+ * two surfaces R-GA-1 holds to agreement. The tail is its own constant so the
45
+ * cancelled toast composes the SAME clause without splitting a literal at
46
+ * runtime (a reword of the cause must never render 'undefined' into copy). */
47
+ export const READING_WORKS_NORMAL = 'reading works as normal';
48
+ export const UNGRANTED_CHAT_CAUSE = `this Grove wasn't granted chat — ${READING_WORKS_NORMAL}`;
49
+
42
50
  export interface ReachRow {
43
51
  key: 'packaging' | 'answer' | 'read' | 'draft' | 'apply';
44
52
  label: string;
45
53
  state: 'ok' | 'blocked' | 'neutral' | 'elsewhere';
46
54
  cause?: string;
55
+ /** R3-790 — the row's earning affordance: on the ✗ chat row, an 'Enable chat'
56
+ * control whose CLICK invokes (the host's lazy consent gesture mints on that
57
+ * same activation, site-main#612). Declared here so the card stays the one
58
+ * computed surface; the panel renders it, never invents it. */
59
+ action?: 'enable-chat';
47
60
  /** Where a `'elsewhere'` row's outcome happens — rendered after `→`, never a ✗. */
48
61
  destination?: string;
49
62
  /** Chips this row contributes when ✓ — the panel renders exactly these. */
@@ -150,7 +163,11 @@ export function computeReachRows({
150
163
  key: 'answer',
151
164
  label: 'Answer questions about this wiki',
152
165
  state: 'blocked',
153
- cause: "this Grove wasn't granted chat — reading works as normal",
166
+ cause: UNGRANTED_CHAT_CAUSE,
167
+ // R3-790: the host's MARK says the grant is missing (provider and key state
168
+ // unknown — the mark does not tell); the invoke this row's own click makes is
169
+ // what the host's lazy gesture earns on that same activation.
170
+ action: 'enable-chat',
154
171
  };
155
172
  } else if (!chatGranted) {
156
173
  answer = catalogAnswered
@@ -158,7 +175,13 @@ export function computeReachRows({
158
175
  key: 'answer',
159
176
  label: 'Answer questions about this wiki',
160
177
  state: 'blocked',
161
- cause: "this Grove wasn't granted chat — reading works as normal",
178
+ cause: UNGRANTED_CHAT_CAUSE,
179
+ // R3-790: the affordance only when a provider RESOLVES (status
180
+ // 'configured' with the grant missing) — on a keyless host the invoke's
181
+ // earning path is the SP-7 connect flow, which the not-configured copy
182
+ // already names (Settings); offering both rows' affordances here would
183
+ // blur which half is missing.
184
+ ...(providerState.status === 'configured' ? { action: 'enable-chat' as const } : {}),
162
185
  }
163
186
  : // The host has not answered the catalog. We know nothing about the grant, so name
164
187
  // nothing — the same shape `unknown` uses for an unanswered provider.
package/src/lib/shell.ts CHANGED
@@ -44,8 +44,9 @@ export interface GroveShell {
44
44
  /** True when the host REFUSED the last edit request — render it where the
45
45
  * affordance was offered (3.3.1, R3-608); a cancelled request never sets it. */
46
46
  editRefused: boolean;
47
- /** What a save actually does, for the affordance's title — under dispatch it says that
48
- * proposing a change back to the content repo is not wired yet (R3-266's residual). */
47
+ /** What a save actually does, for the affordance's title — under dispatch it says
48
+ * the write lands in the mounted content and can be proposed back as a PR
49
+ * (R3-643 / site-main #576 wired the contribute flow to the content repo). */
49
50
  editHint: string;
50
51
  siteTitle: string;
51
52
  /** Interpreter mode (TRUST_MODES §5): render this entry's body through the