@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.
- package/llms.txt +1 -1
- package/package.json +3 -3
- package/src/GroveApp.css +37 -0
- package/src/GroveWiki.tsx +24 -5
- package/src/components/GroveAgent.test.tsx +282 -91
- package/src/components/GroveAgent.tsx +73 -13
- package/src/components/GroveAgent.unanswered.test.tsx +45 -0
- package/src/components/PageView.tsx +6 -0
- package/src/hooks/useCatalogAnswered.late.test.tsx +59 -0
- package/src/hooks/useCatalogAnswered.test.tsx +89 -0
- package/src/hooks/useCatalogAnswered.ts +73 -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/content.test.ts +47 -1
- package/src/lib/content.ts +21 -2
- package/src/lib/reachCard.test.ts +507 -61
- package/src/lib/reachCard.ts +193 -28
- package/src/lib/shell.ts +3 -2
|
@@ -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
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
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
|
-
() =>
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
208
|
-
:
|
|
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
|
-
*
|
|
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
|
+
});
|