Skip to content

Commit 8e24c4c

Browse files
mpstatonclaude
andcommitted
attempt(augment-from-db, search-and-add, step3): the heartbeat loop is chrome — editable term, provider palette, one-click add wired back to the card
apps/search-and-add (:3016) is the flow's second remote. Click 🔍 next to any list on the org card → the shell opens the orgWorkbench+searchAndAdd pairing (card keeps 55%) → the seeded term sits in an always-editable TermBar (the flow's standing constraint made chrome) → results fire through search.fire → one ➕ per row adds to exactly the list that launched the search → augment-it:entity-updated → the card refetches. ProviderPalette renders connectors.inventory as chips (short_label + cost tier, paid dashed, needs-env dark/unclickable, "auto" = registry free-tier-first, so SearXNG). Auto-fires once per fresh envelope — searches are reads; the gating thesis governs writes, and every write here is one deliberate click with server-side dedup behind it. D2 hardened: the search-request CustomEvent alone is racy across the async federation mount, so the envelope ALSO persists to localStorage['augment-it:search-request'] (the repo's established cross-remount pattern); the remote reads it on mount, listens live after. requestSearch() in org-workbench does localStorage → event → navigate, in that order. Verb routing covers both entity shapes: org links/streams/corpus and person links/corpus (persons have no streams — guarded). Person-shaped envelopes are fully wired; Phase 4's people reveal starts dispatching them. Proof: svelte-check 0/0 both remotes; builds green incl. shell; :3016 remoteEntry.js HTTP 200; Phase 1 proof re-run 7/7. Plan: context-v/plans/Augment-From-DB-Phase-3-Search-And-Add-Remote.md (Shipped). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014zrFkWSVgTkoQyiobdBjrd
1 parent b6ec4da commit 8e24c4c

24 files changed

Lines changed: 896 additions & 4 deletions

apps/org-workbench/src/AdditiveList.svelte

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,14 @@
1313
entries,
1414
kindHint = 'auto-detected from URL',
1515
onadd,
16+
onsearch,
1617
}: {
1718
title: string;
1819
entries: ShapedLink[];
1920
kindHint?: string;
2021
onadd: (url: string, kind?: string) => Promise<void>;
22+
// Optional 🔍 — launches search-and-add pre-scoped to this list (Phase 3).
23+
onsearch?: () => void;
2124
} = $props();
2225
2326
let adding = $state(false);
@@ -61,6 +64,11 @@
6164
<h3 class="ow-list-title">{title} <span class="ow-list-count">{entries.length}</span></h3>
6265
<span class="ow-list-actions">
6366
{#if justAdded}<span class="ow-added">added ✓</span>{/if}
67+
{#if onsearch}
68+
<button type="button" class="ow-plus" title="Search the web for {title}" onclick={onsearch}>
69+
🔍
70+
</button>
71+
{/if}
6472
<button type="button" class="ow-plus" title="Add to {title}" onclick={() => (open = !open)}>
6573
{open ? '×' : '+'}
6674
</button>

apps/org-workbench/src/OrgCard.svelte

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
88
import AdditiveList from './AdditiveList.svelte';
99
import { addOrgLink, addOrgStream, addOrgCorpus } from './lib/org-client';
10-
import type { OrgDetail } from './lib/types';
10+
import { requestSearch } from './lib/search-request';
11+
import type { OrgDetail, SearchRequestDetail } from './lib/types';
1112
1213
let {
1314
org,
@@ -28,6 +29,19 @@
2829
);
2930
};
3031
}
32+
33+
// 🔍 — launch search-and-add pre-scoped to this org + list. Seed terms are
34+
// hardcoded v1 (spec open question: pack-template convergence later); the
35+
// operator rewrites them freely in the TermBar anyway — that's the point.
36+
const displayName = $derived(org.complete_name ?? org.conventional_name ?? org.slug);
37+
function makeSearch(target: SearchRequestDetail['target'], seed: (name: string) => string) {
38+
return () =>
39+
requestSearch({
40+
entity: { type: 'organization', org_slug: org.slug, display_name: displayName },
41+
target,
42+
seed_term: seed(displayName),
43+
});
44+
}
3145
</script>
3246

3347
<article class="ow-card">
@@ -57,18 +71,21 @@
5771
entries={org.org_links}
5872
kindHint="kind (auto: website/linkedin/x/…)"
5973
onadd={makeAdd(addOrgLink)}
74+
onsearch={makeSearch('links', (n) => `"${n}" LinkedIn`)}
6075
/>
6176
<AdditiveList
6277
title="Pulse streams"
6378
entries={org.media_streams}
6479
kindHint="kind (auto: blog_index/rss/newsroom/…)"
6580
onadd={makeAdd(addOrgStream)}
81+
onsearch={makeSearch('streams', (n) => `"${n}" blog`)}
6682
/>
6783
<AdditiveList
6884
title="Corpus items"
6985
entries={org.org_corpus}
7086
kindHint="kind (optional)"
7187
onadd={makeAdd(addOrgCorpus)}
88+
onsearch={makeSearch('corpus', (n) => `"${n}" news`)}
7289
/>
7390
</div>
7491
</article>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Launch the search-and-add remote pre-scoped to one entity + one list
2+
// (spec D2). Three writes, in order:
3+
// 1. localStorage — survives the async federation mount (the event alone
4+
// is racy when search-and-add isn't mounted yet);
5+
// 2. the live CustomEvent — picked up instantly when it IS mounted;
6+
// 3. augment-it:navigate — the shell opens the orgWorkbench+searchAndAdd
7+
// pairing so both surfaces tile side by side.
8+
9+
import type { SearchRequestDetail } from './types';
10+
11+
const SEARCH_REQUEST_KEY = 'augment-it:search-request';
12+
13+
export function requestSearch(detail: SearchRequestDetail): void {
14+
if (typeof localStorage !== 'undefined') {
15+
localStorage.setItem(SEARCH_REQUEST_KEY, JSON.stringify(detail));
16+
}
17+
window.dispatchEvent(new CustomEvent('augment-it:search-request', { detail }));
18+
window.dispatchEvent(
19+
new CustomEvent('augment-it:navigate', { detail: { remoteId: 'searchAndAdd' } }),
20+
);
21+
}

apps/org-workbench/src/lib/types.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,12 @@ export type AffiliatedPerson = {
4141
};
4242

4343
// Phase 3 — the A→B launch envelope for the search-and-add remote (spec D2).
44-
// Dispatched as CustomEvent('augment-it:search-request', { detail }).
44+
// Dispatched as CustomEvent('augment-it:search-request', { detail }) AND
45+
// persisted to localStorage (see lib/search-request.ts for why both).
4546
export type SearchRequestDetail = {
4647
entity:
47-
| { type: 'organization'; org_slug: string }
48-
| { type: 'person'; person_uuid: string };
48+
| { type: 'organization'; org_slug: string; display_name?: string }
49+
| { type: 'person'; person_uuid: string; display_name?: string };
4950
target: 'links' | 'corpus' | 'streams';
5051
seed_term: string;
5152
intent?: string;

apps/search-and-add/package.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "@augment-it/search-and-add",
3+
"version": "0.0.1",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "rsbuild dev",
8+
"build": "rsbuild build",
9+
"preview": "rsbuild preview",
10+
"check": "svelte-check --tsconfig ./tsconfig.json"
11+
},
12+
"dependencies": {
13+
"@augment-it/theme": "workspace:*",
14+
"@augment-it/workspace": "workspace:*",
15+
"svelte": "^5.56.4"
16+
},
17+
"devDependencies": {
18+
"@module-federation/enhanced": "^2.6.0",
19+
"@module-federation/rsbuild-plugin": "^2.6.0",
20+
"@rsbuild/core": "^2.1.2",
21+
"@rsbuild/plugin-svelte": "^2.0.0",
22+
"svelte-check": "^4.7.1",
23+
"typescript": "^6.0.3"
24+
}
25+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { defineConfig } from '@rsbuild/core';
2+
import { pluginSvelte } from '@rsbuild/plugin-svelte';
3+
import { pluginModuleFederation } from '@module-federation/rsbuild-plugin';
4+
5+
// Federated remote — search-and-add. The second surface of the "Augment from
6+
// DB" flow: launched from any 🔍 on the org-workbench card (pairing tile,
7+
// spec D2), it shows the search term in an always-editable bar, fires through
8+
// the provider palette (SearXNG free default, Exa/Tavily/SerpApi as peers via
9+
// search.fire), and every result row one-click-adds to the launching entity's
10+
// list. Deliberately its own remote, not a component inside org-workbench —
11+
// the same surface will serve person cards (Phase 4) and other flows later.
12+
// See context-v/specs/Augment-From-DB-Flow.md.
13+
export default defineConfig({
14+
plugins: [
15+
pluginSvelte(),
16+
pluginModuleFederation({
17+
name: 'searchAndAdd',
18+
filename: 'remoteEntry.js',
19+
exposes: {
20+
'./mount': './src/mount.ts',
21+
},
22+
dts: false,
23+
}),
24+
],
25+
source: {
26+
entry: { index: './src/index.ts' },
27+
},
28+
output: {
29+
target: 'web',
30+
overrideBrowserslist: ['last 2 Chrome versions', 'last 2 Firefox versions', 'last 2 Safari versions'],
31+
},
32+
tools: {
33+
swc: {
34+
jsc: { target: 'es2022' },
35+
},
36+
},
37+
html: {
38+
title: 'augment-it · search-and-add',
39+
},
40+
server: {
41+
port: 3016,
42+
cors: { origin: ['http://localhost:3100'] }, // the federation shell
43+
},
44+
dev: {
45+
assetPrefix: 'http://localhost:3016',
46+
},
47+
});

apps/search-and-add/src/App.svelte

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<script lang="ts">
2+
// search-and-add — the "Augment from DB" flow's provider-pluggable search
3+
// surface. Launched from any 🔍 on an entity card (spec D2): the envelope
4+
// arrives via localStorage + live CustomEvent, the seed term lands in the
5+
// always-editable TermBar, results fire through search.fire (SearXNG free
6+
// default, palette to swap), and every row's ➕ adds to the launching
7+
// entity's list then broadcasts augment-it:entity-updated so the card
8+
// refetches. Auto-fires once per fresh envelope — searches are reads; the
9+
// gating thesis governs writes, and every write here is one operator click.
10+
// See context-v/specs/Augment-From-DB-Flow.md §Phase 3.
11+
12+
import { onMount } from 'svelte';
13+
import { workspace } from '@augment-it/workspace';
14+
import TermBar from './TermBar.svelte';
15+
import ProviderPalette from './ProviderPalette.svelte';
16+
import ResultsList from './ResultsList.svelte';
17+
import { searchContext } from './lib/search-context.svelte';
18+
import { fireSearch, fetchConnectors, addResult, verbFor } from './lib/search-client';
19+
import type { ConnectorInfo, ConnectorResult } from './lib/types';
20+
21+
const TOKEN_KEY = 'augment-it:session-token';
22+
const WS_URL = 'ws://localhost:3001/ws';
23+
24+
let status = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
25+
let client = $state<string>('reach-edu');
26+
27+
let connectors = $state<ConnectorInfo[]>([]);
28+
let selectedProvider = $state<string | null>(null); // null = auto → SearXNG
29+
30+
let term = $state('');
31+
let firing = $state(false);
32+
let fireError = $state<string | null>(null);
33+
let results = $state<ConnectorResult[]>([]);
34+
let firedVia = $state<string | null>(null);
35+
36+
let firedArrival = -1;
37+
38+
const req = $derived(searchContext.request);
39+
const entityLabel = $derived(
40+
req
41+
? req.entity.display_name ??
42+
(req.entity.type === 'organization' ? req.entity.org_slug : req.entity.person_uuid)
43+
: null,
44+
);
45+
const addVerb = $derived.by(() => {
46+
if (!req) return null;
47+
try {
48+
return verbFor(req);
49+
} catch {
50+
return null;
51+
}
52+
});
53+
54+
async function fire() {
55+
if (!term.trim()) return;
56+
firing = true;
57+
fireError = null;
58+
try {
59+
const r = await fireSearch({
60+
query: term.trim(),
61+
intent: req?.intent,
62+
provider: selectedProvider ?? undefined,
63+
});
64+
results = r.results;
65+
firedVia = r.provider;
66+
} catch (err) {
67+
fireError = err instanceof Error ? err.message : String(err);
68+
results = [];
69+
firedVia = null;
70+
} finally {
71+
firing = false;
72+
}
73+
}
74+
75+
// A fresh envelope (mount-time localStorage read counts, via arrival 0 vs
76+
// firedArrival -1) seeds the term and auto-fires exactly once. Later
77+
// operator edits + re-fires never re-trigger this.
78+
$effect(() => {
79+
if (searchContext.arrival !== firedArrival || (firedArrival === -1 && req)) {
80+
firedArrival = searchContext.arrival;
81+
if (req) {
82+
term = req.seed_term;
83+
results = [];
84+
firedVia = null;
85+
void fire();
86+
}
87+
}
88+
});
89+
90+
async function onAdd(url: string) {
91+
if (!req) throw new Error('no launch context — open a 🔍 from an entity card');
92+
await addResult(req, url, client);
93+
}
94+
95+
async function loadActiveClient() {
96+
try {
97+
const r = (await workspace.invoke('workspace.active', {})) as { active_client_id?: string };
98+
if (r?.active_client_id) client = r.active_client_id;
99+
} catch {
100+
/* keep default */
101+
}
102+
}
103+
104+
function onWorkspaceChanged(e: Event) {
105+
const detail = (e as CustomEvent).detail as { client_id?: string } | undefined;
106+
if (detail?.client_id) client = detail.client_id;
107+
else void loadActiveClient();
108+
}
109+
110+
onMount(() => {
111+
workspace.connect({
112+
url: WS_URL,
113+
getToken: () => localStorage.getItem(TOKEN_KEY),
114+
saveToken: (t) => localStorage.setItem(TOKEN_KEY, t),
115+
onStatus: (s) => (status = s),
116+
});
117+
void loadActiveClient();
118+
void fetchConnectors().then((c) => (connectors = c)).catch(() => {});
119+
const unlisten = searchContext.listen();
120+
window.addEventListener('augment-it:workspace-changed', onWorkspaceChanged);
121+
return () => {
122+
unlisten();
123+
window.removeEventListener('augment-it:workspace-changed', onWorkspaceChanged);
124+
};
125+
});
126+
</script>
127+
128+
<div class="saa-app">
129+
<header class="saa-header">
130+
<div class="saa-title-row">
131+
<h1 class="saa-title">Search &amp; Add</h1>
132+
{#if req}
133+
<span class="saa-context">
134+
adding to <strong>{entityLabel}</strong> · {req.target}
135+
{#if !addVerb}<em class="saa-context-warn">(no add verb for this combination)</em>{/if}
136+
</span>
137+
{:else}
138+
<span class="saa-context saa-context-none">no launch context — open a 🔍 from an entity card</span>
139+
{/if}
140+
<span class="saa-ws status-{status}">{status}</span>
141+
</div>
142+
<TermBar bind:term {firing} onfire={fire} />
143+
<ProviderPalette {connectors} bind:selected={selectedProvider} />
144+
{#if fireError}<div class="saa-error saa-fire-error">{fireError}</div>{/if}
145+
</header>
146+
147+
<main class="saa-body">
148+
<ResultsList {results} provider={firedVia} onadd={onAdd} />
149+
</main>
150+
</div>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<script lang="ts">
2+
// Provider chips from connectors.inventory — short_label + cost tier,
3+
// needs-env/disabled rendered dark and unclickable (the palette shows
4+
// what COULD fire, not just what can). 'auto' = let the registry resolve
5+
// free-tier-first for the intent (search.fire with no provider arg).
6+
// Borrowed shape: apps/pack-runner's ConnectorPalette/ConnectorChip.
7+
8+
import type { ConnectorInfo } from './lib/types';
9+
10+
let {
11+
connectors,
12+
selected = $bindable(),
13+
}: {
14+
connectors: ConnectorInfo[];
15+
selected: string | null; // null = auto (registry default)
16+
} = $props();
17+
18+
// Search-shaped connectors only — a palette chip must be able to serve
19+
// search.fire's default intent.
20+
const searchable = $derived(
21+
connectors.filter((c) => c.capabilities.some((cap) => cap.startsWith('search.'))),
22+
);
23+
24+
function pick(id: string | null) {
25+
selected = id;
26+
}
27+
</script>
28+
29+
<div class="saa-palette" role="radiogroup" aria-label="Search provider">
30+
<button
31+
type="button"
32+
class="saa-chip"
33+
class:active={selected === null}
34+
onclick={() => pick(null)}
35+
title="Let the registry pick — free tier first"
36+
>
37+
auto
38+
</button>
39+
{#each searchable as c (c.id)}
40+
<button
41+
type="button"
42+
class="saa-chip tier-{c.cost_tier}"
43+
class:active={selected === c.id}
44+
class:dark={c.status !== 'available'}
45+
disabled={c.status !== 'available'}
46+
onclick={() => pick(c.id)}
47+
title="{c.display_name} · {c.cost_tier}{c.status !== 'available' ? ` · ${c.status}` : ''}"
48+
>
49+
{c.short_label}
50+
</button>
51+
{/each}
52+
</div>

0 commit comments

Comments
 (0)