@immediately-run/grove 0.1.7 → 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.
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/src/GroveApp.css +20 -0
- package/src/components/GroveAgent.test.tsx +152 -0
- package/src/components/GroveAgent.tsx +21 -3
- package/src/components/PageView.tsx +6 -0
- package/src/hooks/useEditAffordance.ts +6 -9
- package/src/hooks/useHeadingFragmentUrl.test.tsx +146 -0
- package/src/hooks/useHeadingFragmentUrl.ts +98 -0
- package/src/lib/reachCard.test.ts +28 -0
- package/src/lib/reachCard.ts +25 -2
- package/src/lib/shell.ts +3 -2
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.
|
|
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.8)
|
|
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
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
|
-
?
|
|
240
|
-
:
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
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
|
+
});
|
package/src/lib/reachCard.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
|
48
|
-
*
|
|
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
|