Skip to content

Commit afbfa21

Browse files
mpstatonclaude
andcommitted
feat(org-workbench): Tags chip row on the identity block
Organizations can now be marked for what they ARE — Initiative, Program, Funder, Think-Tank — right on the card's identity block, closing the gap where "funder" existed only as a corpus folder name on disk. OrgCard: a Tags row between Aliases and Domains — chips with the alias-chip ✕-inline-confirm, ➕ opening a one-field add with a datalist from the shared per-client tag_vocab (existing tag.suggest verb). The row renders when empty so the affordance is discoverable. Tag removal rides organization.tag.remove, not resolver.update_org — tags are per-client observations, never fields on the shared multi-tenant org row. lib: OrgDetail.tags, addOrgTag / removeOrgTag / suggestTags wrappers. Verified: svelte-check 0 errors 0 warnings. Browser drive lands with #54. Refs #53. Files changed: - apps/org-workbench/src/OrgCard.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 2e10cdd commit afbfa21

4 files changed

Lines changed: 127 additions & 2 deletions

File tree

apps/org-workbench/src/OrgCard.svelte

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
updateOrgCorpus,
2020
removeOrgCorpus,
2121
updateOrgIdentity,
22+
addOrgTag,
23+
removeOrgTag,
24+
suggestTags,
2225
} from './lib/org-client';
2326
import { requestSearch } from './lib/search-request';
2427
import { submitCrawl } from './lib/search-queue';
@@ -99,7 +102,43 @@
99102
let convDraft = $state('');
100103
let identityBusy = $state(false);
101104
let identityError = $state<string | null>(null);
102-
let pendingChip = $state<{ kind: 'alias' | 'domain'; value: string } | null>(null);
105+
let pendingChip = $state<{ kind: 'alias' | 'domain' | 'tag'; value: string } | null>(null);
106+
107+
// Tags — has_tag observations per client (Initiative / Program / Funder…).
108+
// Datalist rides the shared per-client tag_vocab via tag.suggest; fetched
109+
// lazily when the add input opens.
110+
let addingTag = $state(false);
111+
let tagDraft = $state('');
112+
let tagVocab = $state<string[]>([]);
113+
114+
async function openTagAdd() {
115+
addingTag = !addingTag;
116+
if (addingTag && tagVocab.length === 0) {
117+
try {
118+
tagVocab = await suggestTags(client);
119+
} catch {
120+
/* datalist is a convenience — the input works without it */
121+
}
122+
}
123+
}
124+
125+
async function commitTagAdd(e: SubmitEvent) {
126+
e.preventDefault();
127+
const t = tagDraft.trim();
128+
if (!t) return;
129+
identityBusy = true;
130+
identityError = null;
131+
try {
132+
await addOrgTag({ org_slug: org.slug, tag: t, client });
133+
tagDraft = '';
134+
addingTag = false;
135+
bump();
136+
} catch (err) {
137+
identityError = err instanceof Error ? err.message : String(err);
138+
} finally {
139+
identityBusy = false;
140+
}
141+
}
103142
104143
function startNamesEdit() {
105144
nameDraft = org.complete_name ?? '';
@@ -144,8 +183,19 @@
144183
if (!pendingChip) return;
145184
if (pendingChip.kind === 'alias') {
146185
void identityWrite({ aliases: org.aliases.filter((a) => a !== pendingChip!.value) });
147-
} else {
186+
} else if (pendingChip.kind === 'domain') {
148187
void identityWrite({ domains: org.domains.filter((d) => d.domain !== pendingChip!.value) });
188+
} else {
189+
// tag — rides its own verb, not resolver.update_org
190+
identityBusy = true;
191+
identityError = null;
192+
void removeOrgTag({ org_slug: org.slug, tag: pendingChip.value, client })
193+
.then(() => {
194+
pendingChip = null;
195+
bump();
196+
})
197+
.catch((err) => (identityError = err instanceof Error ? err.message : String(err)))
198+
.finally(() => (identityBusy = false));
149199
}
150200
}
151201
@@ -212,6 +262,39 @@
212262
{/each}
213263
</dd>
214264
{/if}
265+
<dt>Tags</dt>
266+
<dd>
267+
{#each org.tags as tag (tag)}
268+
<span class="ow-chip">
269+
{tag}
270+
<button
271+
type="button"
272+
class="ow-chip-x ow-micro"
273+
title="remove tag"
274+
onclick={() => (pendingChip = { kind: 'tag', value: tag })}
275+
>×</button>
276+
</span>
277+
{/each}
278+
<button type="button" class="ow-entry-action ow-micro" title="add a tag (Initiative / Program / Funder…)" onclick={() => void openTagAdd()}>
279+
{addingTag ? '×' : '+'}
280+
</button>
281+
{#if addingTag}
282+
<form class="ow-add ow-tag-add" onsubmit={commitTagAdd}>
283+
<input
284+
class="ow-add-kind"
285+
type="text"
286+
list="ow-tag-vocab"
287+
placeholder="tag (Train-Case by convention)"
288+
bind:value={tagDraft}
289+
disabled={identityBusy}
290+
/>
291+
<button type="submit" class="ow-add-go" disabled={identityBusy}>{identityBusy ? '' : 'Tag'}</button>
292+
</form>
293+
<datalist id="ow-tag-vocab">
294+
{#each tagVocab as t (t)}<option value={t}></option>{/each}
295+
</datalist>
296+
{/if}
297+
</dd>
215298
{#if org.domains.length > 0}
216299
<dt>Domains</dt>
217300
<dd>

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,34 @@ export async function patchOrgRelation(args: {
371371
if (!r.ok) throw new Error(r.error || 'organization.relation.update failed');
372372
}
373373

374+
// ---- Org tags — has_tag observations per client; vocabulary rides the
375+
// existing tag_vocab via tag.suggest (shared with source tags on purpose).
376+
377+
export async function addOrgTag(args: { org_slug: string; tag: string; client: string }): Promise<string> {
378+
const r = (await workspace.invoke('organization.tag.add', args)) as {
379+
ok: boolean;
380+
tag?: string;
381+
error?: string;
382+
};
383+
if (!r.ok || !r.tag) throw new Error(r.error || 'organization.tag.add failed');
384+
return r.tag;
385+
}
386+
387+
export async function removeOrgTag(args: { org_slug: string; tag: string; client: string }): Promise<void> {
388+
const r = (await workspace.invoke('organization.tag.remove', args)) as { ok: boolean; error?: string };
389+
if (!r.ok) throw new Error(r.error || 'organization.tag.remove failed');
390+
}
391+
392+
export async function suggestTags(client: string, prefix?: string): Promise<string[]> {
393+
const r = (await workspace.invoke('tag.suggest', { client_slug: client, prefix })) as {
394+
ok: boolean;
395+
tags?: string[];
396+
error?: string;
397+
};
398+
if (!r.ok) throw new Error(r.error || 'tag.suggest failed');
399+
return r.tags ?? [];
400+
}
401+
374402
// Detach a person from one org — the inverse of affiliatePerson. Edge-only:
375403
// person, org, and observation history all stay.
376404
export async function unaffiliatePerson(args: {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export type OrgDetail = {
3232
org_links: ShapedLink[];
3333
media_streams: StreamEntry[];
3434
org_corpus: (ShapedLink & { content_id?: unknown })[];
35+
// Per-client has_tag observations (Initiative, Program, Funder, …) —
36+
// dashed values, operator-owned casing.
37+
tags: string[];
3538
};
3639

3740
// Org↔org relations (organization.relations) — parent/child/peer projected

changelog/2026-07-27_01_Organizations-Learn-Their-Family-Tree-Parent-Child-Peer-Relations-Plus-Org-Tags.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,14 @@ org creation keeps its single door. ✎ edits rel/kind/description in place
134134
(a parent↔child flip re-normalizes server-side); × uses the same
135135
inline-confirm as the alias chips, with the reassurance spelled out:
136136
*both orgs stay — only the edge goes*.
137+
138+
### Tags join the identity block (#53)
139+
140+
A **Tags** row now sits in the identity `<dl>` between Aliases and
141+
Domains: chips with the same ✕-inline-confirm the alias chips use, and a
142+
➕ that opens a one-field add with a datalist fed by the shared per-client
143+
`tag_vocab` (via the existing `tag.suggest` — org tags and source tags
144+
deliberately share one vocabulary). Tag removal rides its own verb rather
145+
than `resolver.update_org`, because tags are per-client observations, not
146+
fields on the shared org row. The row renders even when empty so the
147+
affordance is discoverable — an untagged org shows the ➕, not nothing.

0 commit comments

Comments
 (0)