Skip to content

Commit 2e10cdd

Browse files
mpstatonclaude
andcommitted
feat(org-workbench): RelatedOrgs section — parent/child/peer on the card, click-through navigation
The org card now shows the org's family tree: Part of / Contains / Peers groups with kind badges and free-text descriptions, and every related org is a click away — the row navigates the workbench to it, making constellation-walking (Koch / Stand Together, USDA↔Rural Development) an edge-by-edge browse instead of a search each time. RelatedOrgs.svelte: fetches organization.relations per card, inline ➕ relate form (OrgSearch picker + plain-language parent/child/peer select + kind datalist + description), ✎ patches rel/kind/description via organization.relation.update, × removes with the alias-chip inline-confirm pattern. Relates to existing orgs only — creation stays doored through OrgCreateInline. OrgCard gains an onopen prop (forwarded from App.loadOrg, which already owns active-entity broadcast + localStorage restore). lib: OrgRelKind / RelatedOrg / OrgRelations types + four invoke wrappers. Verified: svelte-check 0 errors 0 warnings. Browser drive lands with #54. Refs #52. Files changed: - apps/org-workbench/src/RelatedOrgs.svelte (new) - apps/org-workbench/src/OrgCard.svelte - apps/org-workbench/src/App.svelte - apps/org-workbench/src/lib/types.ts - apps/org-workbench/src/lib/org-client.ts - changelog/2026-07-27_01_Organizations-Learn-Their-Family-Tree-Parent-Child-Peer-Relations-Plus-Org-Tags.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW28dw3kQAKXr2ZNefCukE
1 parent 62ab198 commit 2e10cdd

6 files changed

Lines changed: 368 additions & 1 deletion

File tree

apps/org-workbench/src/App.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@
184184
{:else if error}
185185
<div class="ow-error">{error}</div>
186186
{:else if org}
187-
<OrgCard {org} {client} onchanged={refetch} />
187+
<OrgCard {org} {client} onchanged={refetch} onopen={(slug) => void loadOrg(slug)} />
188188
{:else}
189189
<p class="ow-empty-state">
190190
Pick an organization from the coverage roster on the left (fewest corpus items first),

apps/org-workbench/src/OrgCard.svelte

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
88
import AdditiveList from './AdditiveList.svelte';
99
import PeopleReveal from './PeopleReveal.svelte';
10+
import RelatedOrgs from './RelatedOrgs.svelte';
1011
import {
1112
addOrgLink,
1213
addOrgStream,
@@ -27,10 +28,13 @@
2728
org,
2829
client,
2930
onchanged,
31+
onopen,
3032
}: {
3133
org: OrgDetail;
3234
client: string;
3335
onchanged: () => void;
36+
// Navigate the workbench to another org — RelatedOrgs' click-through.
37+
onopen: (slug: string) => void;
3438
} = $props();
3539
3640
function bump() {
@@ -279,6 +283,8 @@
279283
onsearch={makeSearch('corpus', (n) => `"${n}" news`)}
280284
/>
281285

286+
<RelatedOrgs org_slug={org.slug} {client} {onopen} />
287+
282288
<PeopleReveal org_slug={org.slug} orgName={displayName} {client} />
283289
</div>
284290
</article>
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
<script lang="ts">
2+
// Related organizations — the parent/child/peer section of the org card.
3+
// Three groups (Part of / Contains / Peers), each row click-through-able:
4+
// walking the Koch constellation edge by edge is the payoff interaction.
5+
// Add relates to EXISTING orgs only — creation stays doored through the
6+
// header's gated + New organization (OrgCreateInline).
7+
// Per context-v/plans/Org-Relations-Parent-Child-Peer-Plus-Org-Tags.md §2.1.
8+
9+
import OrgSearch from './OrgSearch.svelte';
10+
import { fetchOrgRelations, relateOrg, unrelateOrg, patchOrgRelation } from './lib/org-client';
11+
import type { OrgRelations, OrgRelKind, OrgSuggestion, RelatedOrg } from './lib/types';
12+
13+
let {
14+
org_slug,
15+
client,
16+
onopen,
17+
}: {
18+
org_slug: string;
19+
client: string;
20+
onopen: (slug: string) => void;
21+
} = $props();
22+
23+
// Open vocabulary — datalist suggestions, never enum-enforced.
24+
const KIND_SUGGESTIONS = [
25+
'initiative_of',
26+
'fund_of',
27+
'program_of',
28+
'agency_of',
29+
'chapter_of',
30+
'funds',
31+
'partners_with',
32+
];
33+
34+
let relations = $state<OrgRelations>({ parents: [], children: [], peers: [] });
35+
let loading = $state(false);
36+
let error = $state<string | null>(null);
37+
38+
let adding = $state(false);
39+
let picked = $state<OrgSuggestion | null>(null);
40+
let addRel = $state<OrgRelKind>('parent');
41+
let addKind = $state('');
42+
let addDescription = $state('');
43+
let busy = $state(false);
44+
let note = $state<string | null>(null);
45+
46+
let editingSlug = $state<string | null>(null);
47+
let editRel = $state<OrgRelKind>('parent');
48+
let editKind = $state('');
49+
let editDescription = $state('');
50+
let pendingRemove = $state<RelatedOrg | null>(null);
51+
52+
const total = $derived(
53+
relations.parents.length + relations.children.length + relations.peers.length,
54+
);
55+
56+
async function load() {
57+
loading = true;
58+
error = null;
59+
try {
60+
relations = await fetchOrgRelations(org_slug, client);
61+
} catch (err) {
62+
error = err instanceof Error ? err.message : String(err);
63+
} finally {
64+
loading = false;
65+
}
66+
}
67+
68+
// Fresh org card → fresh relations, and drop any in-flight edit state.
69+
$effect(() => {
70+
void org_slug;
71+
adding = false;
72+
picked = null;
73+
editingSlug = null;
74+
pendingRemove = null;
75+
note = null;
76+
void load();
77+
});
78+
79+
async function commitAdd(e: SubmitEvent) {
80+
e.preventDefault();
81+
if (!picked) return;
82+
busy = true;
83+
error = null;
84+
try {
85+
const { created } = await relateOrg({
86+
org_slug,
87+
other_slug: picked.slug,
88+
rel: addRel,
89+
kind: addKind.trim() || null,
90+
description: addDescription.trim() || null,
91+
client,
92+
});
93+
note = created ? null : 'already related — edit the existing relation instead';
94+
adding = false;
95+
picked = null;
96+
addKind = '';
97+
addDescription = '';
98+
await load();
99+
} catch (err) {
100+
error = err instanceof Error ? err.message : String(err);
101+
} finally {
102+
busy = false;
103+
}
104+
}
105+
106+
function startEdit(r: RelatedOrg) {
107+
editingSlug = r.slug;
108+
editRel = r.rel;
109+
editKind = r.kind ?? '';
110+
editDescription = r.description ?? '';
111+
pendingRemove = null;
112+
}
113+
114+
async function commitEdit(e: SubmitEvent) {
115+
e.preventDefault();
116+
if (!editingSlug) return;
117+
busy = true;
118+
error = null;
119+
try {
120+
await patchOrgRelation({
121+
org_slug,
122+
other_slug: editingSlug,
123+
rel: editRel,
124+
kind: editKind.trim() || null,
125+
description: editDescription.trim() || null,
126+
client,
127+
});
128+
editingSlug = null;
129+
await load();
130+
} catch (err) {
131+
error = err instanceof Error ? err.message : String(err);
132+
} finally {
133+
busy = false;
134+
}
135+
}
136+
137+
async function commitRemove() {
138+
if (!pendingRemove) return;
139+
busy = true;
140+
error = null;
141+
try {
142+
await unrelateOrg({ org_slug, other_slug: pendingRemove.slug, client });
143+
pendingRemove = null;
144+
await load();
145+
} catch (err) {
146+
error = err instanceof Error ? err.message : String(err);
147+
} finally {
148+
busy = false;
149+
}
150+
}
151+
</script>
152+
153+
{#snippet relRow(r: RelatedOrg)}
154+
<li class="ro-row">
155+
{#if editingSlug === r.slug}
156+
<form class="ow-add ro-edit" onsubmit={commitEdit}>
157+
<span class="ro-edit-name">{r.display_name}</span>
158+
<select class="ow-add-kind" bind:value={editRel} disabled={busy}>
159+
<option value="parent">parent of this org</option>
160+
<option value="child">child of this org</option>
161+
<option value="peer">peer</option>
162+
</select>
163+
<input class="ow-add-kind" type="text" list="ro-kinds" placeholder="kind (initiative_of/…)" bind:value={editKind} disabled={busy} />
164+
<input class="ow-add-url" type="text" placeholder="description (free text)" bind:value={editDescription} disabled={busy} />
165+
<button type="submit" class="ow-add-go" disabled={busy}>{busy ? '' : 'Save'}</button>
166+
<button type="button" class="ow-add-go" onclick={() => (editingSlug = null)} disabled={busy}>×</button>
167+
</form>
168+
{:else}
169+
<button type="button" class="ro-open" title="open {r.slug} in the workbench" onclick={() => onopen(r.slug)}>
170+
{r.display_name}
171+
</button>
172+
{#if r.kind}<span class="ro-kind">{r.kind}</span>{/if}
173+
{#if r.description}<span class="ro-desc">{r.description}</span>{/if}
174+
<span class="ro-actions">
175+
<button type="button" class="ow-entry-action ow-micro" title="edit relation" onclick={() => startEdit(r)}>✎</button>
176+
<button type="button" class="ow-entry-action ow-micro" title="remove relation" onclick={() => (pendingRemove = r)}>×</button>
177+
</span>
178+
{/if}
179+
</li>
180+
{/snippet}
181+
182+
<section class="ro-section">
183+
<header class="ow-list-head">
184+
<h3 class="ow-list-title">Related organizations{#if !loading}&nbsp;<span class="ow-list-count">{total}</span>{/if}</h3>
185+
<span class="ow-list-actions">
186+
<button
187+
type="button"
188+
class="ow-plus"
189+
title="relate an existing organization (parent / child / peer)"
190+
onclick={() => { adding = !adding; note = null; }}
191+
>
192+
{adding ? '×' : '+'}
193+
</button>
194+
</span>
195+
</header>
196+
197+
{#if note}<p class="ow-empty">{note}</p>{/if}
198+
{#if error}<div class="ow-error">{error}</div>{/if}
199+
200+
{#if adding}
201+
<div class="ro-add">
202+
{#if picked}
203+
<form class="ow-add" onsubmit={commitAdd}>
204+
<span class="ro-picked">{picked.complete_name ?? picked.conventional_name ?? picked.slug}</span>
205+
<select class="ow-add-kind" bind:value={addRel} disabled={busy}>
206+
<option value="parent">is the parent of this org</option>
207+
<option value="child">is a child of this org</option>
208+
<option value="peer">is a peer</option>
209+
</select>
210+
<input class="ow-add-kind" type="text" list="ro-kinds" placeholder="kind (initiative_of/…)" bind:value={addKind} disabled={busy} />
211+
<input class="ow-add-url" type="text" placeholder="description (free text — the context humans hold)" bind:value={addDescription} disabled={busy} />
212+
<button type="submit" class="ow-add-go" disabled={busy}>{busy ? '' : 'Relate'}</button>
213+
<button type="button" class="ow-add-go" onclick={() => (picked = null)} disabled={busy}>×</button>
214+
</form>
215+
{:else}
216+
<OrgSearch {client} onpick={(s) => (picked = s)} />
217+
<p class="ow-empty">relates to existing organizations only — create missing orgs via “+ New organization” first</p>
218+
{/if}
219+
</div>
220+
{/if}
221+
222+
{#if loading}
223+
<p class="ow-empty">loading relations…</p>
224+
{:else if total === 0 && !adding}
225+
<p class="ow-empty">no related organizations yet</p>
226+
{:else}
227+
{#if relations.parents.length > 0}
228+
<h4 class="ro-group">Part of</h4>
229+
<ul class="ro-list">{#each relations.parents as r (r.slug)}{@render relRow(r)}{/each}</ul>
230+
{/if}
231+
{#if relations.children.length > 0}
232+
<h4 class="ro-group">Contains</h4>
233+
<ul class="ro-list">{#each relations.children as r (r.slug)}{@render relRow(r)}{/each}</ul>
234+
{/if}
235+
{#if relations.peers.length > 0}
236+
<h4 class="ro-group">Peers</h4>
237+
<ul class="ro-list">{#each relations.peers as r (r.slug)}{@render relRow(r)}{/each}</ul>
238+
{/if}
239+
{/if}
240+
241+
{#if pendingRemove}
242+
<p class="ow-chip-confirm">
243+
remove the relation to <strong>{pendingRemove.display_name}</strong>? (both orgs stay — only the edge goes)
244+
<button type="button" class="ow-add-go ow-remove-yes" disabled={busy} onclick={commitRemove}>{busy ? '' : 'yes'}</button>
245+
<button type="button" class="ow-add-go" disabled={busy} onclick={() => (pendingRemove = null)}>keep</button>
246+
</p>
247+
{/if}
248+
249+
<datalist id="ro-kinds">
250+
{#each KIND_SUGGESTIONS as k (k)}<option value={k}></option>{/each}
251+
</datalist>
252+
</section>
253+
254+
<style>
255+
.ro-section { display: flex; flex-direction: column; gap: 0.35rem; }
256+
.ro-group { margin: 0.35rem 0 0.1rem; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.65; }
257+
.ro-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.2rem; }
258+
.ro-row { display: flex; align-items: baseline; gap: 0.5rem; min-width: 0; }
259+
.ro-open { background: none; border: none; padding: 0; color: inherit; font: inherit; cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; }
260+
.ro-open:hover { text-decoration-style: solid; }
261+
.ro-kind { font-size: 0.72rem; padding: 0.05rem 0.4rem; border: 1px solid var(--color-border, #2a2c33); border-radius: 999px; opacity: 0.8; white-space: nowrap; }
262+
.ro-desc { font-size: 0.78rem; opacity: 0.6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
263+
.ro-actions { margin-left: auto; display: inline-flex; gap: 0.25rem; }
264+
.ro-row:not(:hover) .ro-actions { visibility: hidden; }
265+
.ro-add { display: flex; flex-direction: column; gap: 0.25rem; }
266+
.ro-picked, .ro-edit-name { font-size: 0.85rem; font-weight: 600; white-space: nowrap; }
267+
</style>

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import type {
1313
ShapedLink,
1414
PersonCandidate,
1515
PersonNormRecord,
16+
OrgRelations,
17+
OrgRelKind,
1618
} from './types';
1719

1820
export async function searchOrgs(q: string, client: string): Promise<OrgSuggestion[]> {
@@ -315,6 +317,60 @@ async function personEntryOp(verb: string, args: PersonRemoveArgs): Promise<void
315317
export const removePersonLink = (args: PersonRemoveArgs) => personEntryOp('person.links.remove', args);
316318
export const removePersonCorpus = (args: PersonRemoveArgs) => personEntryOp('person.corpus.remove', args);
317319

320+
// ---- Org↔org relations — parent/child/peer edges in the affiliations table,
321+
// spoken relative to the focused org (the server normalizes direction). Per
322+
// context-v/plans/Org-Relations-Parent-Child-Peer-Plus-Org-Tags.md.
323+
324+
export async function fetchOrgRelations(org_slug: string, client: string): Promise<OrgRelations> {
325+
const r = (await workspace.invoke('organization.relations', { org_slug, client })) as {
326+
ok: boolean;
327+
parents?: OrgRelations['parents'];
328+
children?: OrgRelations['children'];
329+
peers?: OrgRelations['peers'];
330+
error?: string;
331+
};
332+
if (!r.ok) throw new Error(r.error || 'organization.relations failed');
333+
return { parents: r.parents ?? [], children: r.children ?? [], peers: r.peers ?? [] };
334+
}
335+
336+
export async function relateOrg(args: {
337+
org_slug: string;
338+
other_slug: string;
339+
rel: OrgRelKind;
340+
kind?: string | null;
341+
description?: string | null;
342+
client: string;
343+
}): Promise<{ created: boolean }> {
344+
const r = (await workspace.invoke('organization.relate', args)) as {
345+
ok: boolean;
346+
created?: boolean;
347+
error?: string;
348+
};
349+
if (!r.ok) throw new Error(r.error || 'organization.relate failed');
350+
return { created: r.created ?? false };
351+
}
352+
353+
export async function unrelateOrg(args: {
354+
org_slug: string;
355+
other_slug: string;
356+
client: string;
357+
}): Promise<void> {
358+
const r = (await workspace.invoke('organization.unrelate', args)) as { ok: boolean; error?: string };
359+
if (!r.ok) throw new Error(r.error || 'organization.unrelate failed');
360+
}
361+
362+
export async function patchOrgRelation(args: {
363+
org_slug: string;
364+
other_slug: string;
365+
rel?: OrgRelKind;
366+
kind?: string | null;
367+
description?: string | null;
368+
client: string;
369+
}): Promise<void> {
370+
const r = (await workspace.invoke('organization.relation.update', args)) as { ok: boolean; error?: string };
371+
if (!r.ok) throw new Error(r.error || 'organization.relation.update failed');
372+
}
373+
318374
// Detach a person from one org — the inverse of affiliatePerson. Edge-only:
319375
// person, org, and observation history all stay.
320376
export async function unaffiliatePerson(args: {

0 commit comments

Comments
 (0)