Skip to content

Commit 760e9fe

Browse files
mpstatonclaude
andcommitted
new(org-workbench, coverage): the coverage roster — a filterable org column in front of the flow, fewest corpus first
The operator's job the workbench didn't serve: identify organizations that could and should have more corpus content. A sticky left column now lists every org the workspace client (default reach-edu) can see — name-filterable, sorted by corpus count ascending so zero-corpus orgs surface first, each row carrying corpus · links · streams · people counts with zero-corpus in red. Click a row → the org card opens; every write in the workbench refreshes the counts via the existing entity-updated event. Server: organization.roster — counts ride array::len over the entity lists plus a graph count over the affiliations edges (proven live: 319 reach-edu orgs, long zero-corpus tail); no arrays cross the wire. This ships layer 2 of the corpus-coverage issue (#20), promoted from "folds into the component sweep" to its own build. Closes #32. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UvYzx7vDWeafnkAi2nEQeb
1 parent 1f31fb5 commit 760e9fe

8 files changed

Lines changed: 245 additions & 14 deletions

File tree

apps/org-workbench/src/App.svelte

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import OrgSearch from './OrgSearch.svelte';
1212
import OrgCard from './OrgCard.svelte';
1313
import OrgCreateInline from './OrgCreateInline.svelte';
14+
import OrgRoster from './OrgRoster.svelte';
1415
import { fetchOrgDetail } from './lib/org-client';
1516
import type { OrgDetail, OrgSuggestion } from './lib/types';
1617
@@ -132,18 +133,23 @@
132133
{/if}
133134
</header>
134135

135-
<main class="ow-body">
136-
{#if loading}
137-
<p class="ow-loading">loading…</p>
138-
{:else if error}
139-
<div class="ow-error">{error}</div>
140-
{:else if org}
141-
<OrgCard {org} {client} onchanged={refetch} />
142-
{:else}
143-
<p class="ow-empty-state">
144-
Search for an organization above to open its card — links, pulse streams, corpus items,
145-
and (soon) its people.
146-
</p>
136+
<div class="ow-columns">
137+
{#if status === 'open'}
138+
<OrgRoster {client} activeSlug={org?.slug ?? null} onpick={(slug) => void loadOrg(slug)} />
147139
{/if}
148-
</main>
140+
<main class="ow-body">
141+
{#if loading}
142+
<p class="ow-loading">loading…</p>
143+
{:else if error}
144+
<div class="ow-error">{error}</div>
145+
{:else if org}
146+
<OrgCard {org} {client} onchanged={refetch} />
147+
{:else}
148+
<p class="ow-empty-state">
149+
Pick an organization from the coverage roster on the left (fewest corpus items first),
150+
or search above — the card shows links, pulse streams, corpus items, and people.
151+
</p>
152+
{/if}
153+
</main>
154+
</div>
149155
</div>
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<script lang="ts">
2+
// The coverage column — the front of the workbench flow. Every org the
3+
// workspace client can see, filterable by name/slug, sorted by corpus
4+
// count (fewest first by default — the operator's job here is to find
5+
// orgs that could and should have more corpus content). Zero-corpus rows
6+
// wear a red badge. Clicking a row opens the org card; writes elsewhere
7+
// in the workbench refresh the counts via augment-it:entity-updated.
8+
// Per gh #32 (layer 2 of the corpus-coverage issue).
9+
10+
import { fetchOrgRoster } from './lib/org-client';
11+
import type { OrgRosterRow } from './lib/types';
12+
13+
let {
14+
client,
15+
activeSlug,
16+
onpick,
17+
}: {
18+
client: string;
19+
activeSlug: string | null;
20+
onpick: (org_slug: string) => void;
21+
} = $props();
22+
23+
let rows = $state<OrgRosterRow[]>([]);
24+
let filter = $state('');
25+
let fewestFirst = $state(true);
26+
let loading = $state(false);
27+
let error = $state<string | null>(null);
28+
29+
async function load(c: string) {
30+
loading = true;
31+
error = null;
32+
try {
33+
rows = await fetchOrgRoster(c);
34+
} catch (err) {
35+
error = err instanceof Error ? err.message : String(err);
36+
rows = [];
37+
} finally {
38+
loading = false;
39+
}
40+
}
41+
42+
// Reload whenever the workspace client changes (the default filter IS the
43+
// workspace: client_access CONTAINS <active client>).
44+
$effect(() => {
45+
void load(client);
46+
});
47+
48+
// Any write anywhere in the workbench may have changed a count.
49+
$effect(() => {
50+
const refresh = () => void load(client);
51+
window.addEventListener('augment-it:entity-updated', refresh);
52+
return () => window.removeEventListener('augment-it:entity-updated', refresh);
53+
});
54+
55+
const visible = $derived.by(() => {
56+
const q = filter.trim().toLowerCase();
57+
const filtered = q
58+
? rows.filter((r) =>
59+
`${r.complete_name ?? ''} ${r.conventional_name ?? ''} ${r.slug}`
60+
.toLowerCase()
61+
.includes(q),
62+
)
63+
: rows;
64+
return [...filtered].sort((a, b) =>
65+
fewestFirst ? a.corpus_count - b.corpus_count : b.corpus_count - a.corpus_count,
66+
);
67+
});
68+
69+
function displayName(r: OrgRosterRow): string {
70+
return r.complete_name ?? r.conventional_name ?? r.slug;
71+
}
72+
</script>
73+
74+
<aside class="ow-roster">
75+
<div class="ow-roster-head">
76+
<input
77+
class="ow-roster-filter"
78+
type="search"
79+
placeholder="Filter {rows.length} orgs…"
80+
bind:value={filter}
81+
/>
82+
<button
83+
type="button"
84+
class="ow-roster-sort"
85+
title="Sort by corpus count"
86+
onclick={() => (fewestFirst = !fewestFirst)}
87+
>
88+
corpus {fewestFirst ? '' : ''}
89+
</button>
90+
</div>
91+
92+
{#if loading && rows.length === 0}
93+
<p class="ow-roster-note">loading roster…</p>
94+
{:else if error}
95+
<div class="ow-error">{error}</div>
96+
{:else if visible.length === 0}
97+
<p class="ow-roster-note">no orgs match</p>
98+
{:else}
99+
<ul class="ow-roster-list">
100+
{#each visible as r (r.slug)}
101+
<li>
102+
<button
103+
type="button"
104+
class="ow-roster-row"
105+
class:active={r.slug === activeSlug}
106+
onclick={() => onpick(r.slug)}
107+
>
108+
<span class="ow-roster-name">{displayName(r)}</span>
109+
<span class="ow-roster-counts">
110+
<span class="ow-roster-corpus" class:zero={r.corpus_count === 0}>
111+
{r.corpus_count} corpus
112+
</span>
113+
· {r.link_count} links · {r.stream_count} streams · {r.people_count} people
114+
</span>
115+
</button>
116+
</li>
117+
{/each}
118+
</ul>
119+
{/if}
120+
</aside>

apps/org-workbench/src/app.css

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,23 @@
3333
.ow-search-slug { color: var(--color-text-muted, #9aa0aa); font-size: 0.75rem; flex-shrink: 0; }
3434

3535
/* card */
36-
.ow-body { margin-top: 1rem; }
36+
.ow-columns { display: flex; gap: 1.25rem; align-items: flex-start; margin-top: 1rem; }
37+
.ow-body { flex: 1; min-width: 0; }
38+
39+
/* coverage roster — the column in front of the flow (gh #32) */
40+
.ow-roster { width: 300px; flex-shrink: 0; position: sticky; top: 7.5rem; max-height: calc(100vh - 8.5rem); display: flex; flex-direction: column; border: 1px solid var(--color-border, #2a2c33); border-radius: 8px; background: var(--color-surface, #1c1e25); }
41+
.ow-roster-head { display: flex; gap: 0.4rem; padding: 0.5rem; border-bottom: 1px solid var(--color-border, #2a2c33); }
42+
.ow-roster-filter { flex: 1; min-width: 0; box-sizing: border-box; background: var(--color-bg, #14151a); color: inherit; border: 1px solid var(--color-border, #2a2c33); border-radius: 6px; padding: 0.35rem 0.55rem; font: inherit; font-size: 0.85rem; }
43+
.ow-roster-sort { background: none; border: 1px solid var(--color-border, #2a2c33); border-radius: 6px; color: var(--color-text-muted, #9aa0aa); font: inherit; font-size: 0.8rem; padding: 0.25rem 0.5rem; cursor: pointer; white-space: nowrap; }
44+
.ow-roster-sort:hover { color: inherit; }
45+
.ow-roster-note { padding: 0.75rem; margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.85rem; }
46+
.ow-roster-list { margin: 0; padding: 0.25rem; list-style: none; overflow-y: auto; }
47+
.ow-roster-row { display: flex; flex-direction: column; gap: 0.1rem; width: 100%; text-align: left; background: none; border: none; color: inherit; font: inherit; padding: 0.4rem 0.55rem; border-radius: 5px; cursor: pointer; }
48+
.ow-roster-row:hover { background: var(--color-border, #2a2c33); }
49+
.ow-roster-row.active { background: var(--color-border, #2a2c33); outline: 1px solid var(--color-text-muted, #9aa0aa); }
50+
.ow-roster-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.9rem; }
51+
.ow-roster-counts { font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); }
52+
.ow-roster-corpus.zero { color: var(--color-error-text, #f3a3a3); font-weight: 600; }
3753
.ow-loading, .ow-empty-state { color: var(--color-text-muted, #9aa0aa); }
3854
.ow-card { border: 1px solid var(--color-border, #2a2c33); border-radius: 8px; background: var(--color-surface, #1c1e25); padding: 1rem 1.25rem; }
3955
.ow-card-head { display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap; }

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
OrgSuggestion,
99
OrgDetail,
1010
OrgCandidate,
11+
OrgRosterRow,
1112
AffiliatedPerson,
1213
ShapedLink,
1314
PersonCandidate,
@@ -24,6 +25,18 @@ export async function searchOrgs(q: string, client: string): Promise<OrgSuggesti
2425
return r.candidates ?? [];
2526
}
2627

28+
// The coverage roster — every org this client can see, with counts, fewest
29+
// corpus first.
30+
export async function fetchOrgRoster(client: string): Promise<OrgRosterRow[]> {
31+
const r = (await workspace.invoke('organization.roster', { client })) as {
32+
ok: boolean;
33+
orgs?: OrgRosterRow[];
34+
error?: string;
35+
};
36+
if (!r.ok) throw new Error(r.error || 'organization.roster failed');
37+
return r.orgs ?? [];
38+
}
39+
2740
// Scored candidates for the create gate — every signal the resolver knows
2841
// (slug/domain/fuzzy name), unlike searchOrgs's lighter name-contains.
2942
export async function fetchOrgCandidates(

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ export type AffiliatedPerson = {
6767
personal_corpus_count: number;
6868
};
6969

70+
// One row of the coverage roster (organization.roster) — counts only, no
71+
// arrays; sorted server-side fewest-corpus-first.
72+
export type OrgRosterRow = {
73+
slug: string;
74+
complete_name: string | null;
75+
conventional_name: string | null;
76+
corpus_count: number;
77+
link_count: number;
78+
stream_count: number;
79+
people_count: number;
80+
};
81+
7082
// Scored org candidate (resolver.candidates) — the gate's evidence when the
7183
// operator wants to create an org: slug 100 · domain 90 · fuzzy name 60.
7284
export type OrgCandidate = {

services/record-surrealdb-resolver/src/handlers.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
addOrgCorpus,
2222
addOrgStream,
2323
updateOrgStream,
24+
listOrgRoster,
2425
getOrgDetail,
2526
checkContentUrls,
2627
type NormRecord,
@@ -199,6 +200,23 @@ export function registerHandlers(nc: NatsConnection): void {
199200
}
200201
})();
201202

203+
// organization.roster — the coverage column: per-org counts for a client,
204+
// fewest corpus first. Per gh #32.
205+
(async () => {
206+
const sub = nc.subscribe('organization.roster.requested');
207+
for await (const msg of sub) {
208+
const args = msg.json() as { client: string };
209+
try {
210+
const db = await getDb();
211+
const result = await listOrgRoster(db, args.client);
212+
if (msg.reply) msg.respond(JSON.stringify(result));
213+
} catch (err: unknown) {
214+
const error = err instanceof Error ? err.message : String(err);
215+
if (msg.reply) msg.respond(JSON.stringify({ ok: false, error }));
216+
}
217+
}
218+
})();
219+
202220
// organization.streams.update — patch kind/name on one media_streams entry,
203221
// matched by URL. Per context-v/plans/Workbench-Usability-Sweep-Corpus-
204222
// Visibility-Stream-Editing-Affiliation-Promotion.md.

services/record-surrealdb-resolver/src/resolver.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -941,6 +941,49 @@ export async function updateOrgStream(
941941
return { ok: true, org_id: String(org.id), stream: patched };
942942
}
943943

944+
// organization.roster — the coverage column in front of the workbench flow:
945+
// every org the client can see, with entity-list counts, sorted so the orgs
946+
// that could and should have more corpus surface first. Counts ride
947+
// array::len over the entity lists + a graph count over the affiliations
948+
// edges; no arrays cross the wire.
949+
// Per gh #32 (layer 2 of context-v/issues/Corpus-Items-Not-Visible-On-
950+
// Person-Cards-Coverage-Hard-To-Assess.md, promoted to its own build).
951+
952+
export type OrgRosterRow = {
953+
slug: string;
954+
complete_name: string | null;
955+
conventional_name: string | null;
956+
corpus_count: number;
957+
link_count: number;
958+
stream_count: number;
959+
people_count: number;
960+
};
961+
export type OrgRosterResult = { ok: true; orgs: OrgRosterRow[] };
962+
963+
export async function listOrgRoster(db: Surreal, client: string): Promise<OrgRosterResult> {
964+
const r = await db.query(
965+
`SELECT slug, complete_name, conventional_name,
966+
array::len(org_corpus ?? []) AS corpus_count,
967+
array::len(org_links ?? []) AS link_count,
968+
array::len(media_streams ?? []) AS stream_count,
969+
count(<-affiliations) AS people_count
970+
FROM organizations
971+
WHERE client_access CONTAINS $client
972+
ORDER BY corpus_count ASC;`,
973+
{ client },
974+
);
975+
const rows = ((r?.[0] as Record<string, unknown>[]) ?? []).map((o) => ({
976+
slug: String(o.slug),
977+
complete_name: (o.complete_name as string) ?? null,
978+
conventional_name: (o.conventional_name as string) ?? null,
979+
corpus_count: Number(o.corpus_count ?? 0),
980+
link_count: Number(o.link_count ?? 0),
981+
stream_count: Number(o.stream_count ?? 0),
982+
people_count: Number(o.people_count ?? 0),
983+
}));
984+
return { ok: true, orgs: rows };
985+
}
986+
944987
// ---------------------------------------------------------------------------
945988
// organization.detail — the full org card for the Augment-from-DB org
946989
// workbench: identity, all three additive lists, aliases + domains. Read

services/workspace/src/capabilities.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ const CAPABILITY_TO_SUBJECT: Record<string, string> = {
182182
'organization.detail': 'organization.detail.requested',
183183
'organization.affiliations': 'organization.affiliations.requested',
184184
'organization.streams.add': 'organization.streams.add.requested',
185+
// The coverage column — per-org counts for a client, fewest corpus first.
186+
'organization.roster': 'organization.roster.requested',
185187
// Patch kind/name on one media_streams entry, matched by URL. Per
186188
// context-v/plans/Workbench-Usability-Sweep-Corpus-Visibility-Stream-Editing-Affiliation-Promotion.md.
187189
'organization.streams.update': 'organization.streams.update.requested',
@@ -293,6 +295,7 @@ const CAPABILITY_TIMEOUTS_MS: Record<string, number> = {
293295
'organization.affiliations': 30_000,
294296
'organization.streams.add': 30_000,
295297
'organization.streams.update': 30_000,
298+
'organization.roster': 30_000,
296299
// One query, one provider — pack.search's budget.
297300
'search.fire': 30_000,
298301
// Multi-stage (Firecrawl index harvest + per-post dates + dedup read) —

0 commit comments

Comments
 (0)