Skip to content

Commit 16b4ec2

Browse files
committed
feat(ui): Moss group context for groups and NDOs
Inside Moss the Moss group is the group: the tool instance's provisioned group cell is the one Nondominium group, joined automatically with no invite code. The profile comes from profilesClient, the role from myAccountabilitiesPerGroup, and NDO clones are created through the WeaveClient. A layer choice in LobbyServiceResolved keeps the Lobby DNA, Group DNA and invite codes for the standalone build. NDO clones are not registered as public: Moss 0.16.0-rc.5 cannot register a clone that carries DNA properties (PUBLIC_NDO_CLONES, see #162).
1 parent 7f3dab6 commit 16b4ec2

20 files changed

Lines changed: 858 additions & 41 deletions

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ NDO over a live DHT.
2626
with two agents and one group (`bun run tool-dev`), `@theweave/api` in the UI, and a
2727
connection mode that takes the conductor client from Moss. The standalone build is
2828
unchanged. See `documentation/specifications/moss-integration.md`.
29+
- **Moss group context.** Inside Moss the Moss group is the group: the tool instance's `group` cell is the one Nondominium group, joined automatically with no invite code, the profile comes from Moss, the role from the Moss accountabilities, and NDO clones are created through the `WeaveClient`. Standalone keeps the Lobby, the Group DNA and invite links.
2930
- **Lobby DNA** as the entry point. Groups are announced here for discovery, and NDOs are
3031
browsed and filtered across every group the agent belongs to.
3132
- **Group DNA, one cloned cell per group.** Each group is its own DHT with its own

‎documentation/IMPLEMENTATION_STATUS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ There is no `get_all_groups` extern in a Group cell; each cell represents one is
296296
Nondominium runs as a Moss tool in `weave` dev mode. Standalone (Lobby, Group DNA, invite codes) is unchanged.
297297

298298
- ✅ **Scaffold (#163).** `weave/weave.dev.config.ts` (two agents, one group, tool served from `localhost:8888`), `bun run tool-dev`, `@theweave/api` in `ui/`, host detection and the Moss connection mode in `ui/src/lib/moss/`, an empty `AppletServices`. The UI connects through the applet client Moss hands it.
299-
- ❌ **Moss group context (#164).** Profiles from Moss, roles from `myAccountabilitiesPerGroup()`, the instance's `group` cell as the Nondominium group, NDO clones through the `WeaveClient`. Not yet implemented: inside Moss the Lobby and invite-code flows are not adapted.
299+
- ✅ **Moss group context (#164).** Inside Moss the profile comes from `profilesClient`, the role from `myAccountabilitiesPerGroup()`, the tool instance's provisioned `group` cell is the one Nondominium group (the tool installer creates its profile, every other member joins it automatically, no invite code), and NDO clones are created through the `WeaveClient`. Standalone keeps the Lobby DNA, Group DNA and invite codes. Known gap: NDO clones are not registered as public to the Moss group, because Moss rc.5 cannot register a clone that carries DNA properties (see [moss-integration.md](specifications/moss-integration.md)).
300300
- ❌ **NDOs as assets (#165).** WAL, `getAssetInfo`, `search`, asset view.
301301

302302
See [moss-integration.md](specifications/moss-integration.md).

‎documentation/specifications/moss-integration.md‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Moss Integration
22

3-
**Status:** v0.1.0 pilot, in progress. The tool scaffold and dev config are implemented (#163). The Moss group context (#164) and NDOs as assets (#165) follow. Supersedes the "Moss Integration Contract" in [lobby-architecture.md](lobby-architecture.md) §9 where they differ.
3+
**Status:** v0.1.0 pilot, in progress. The tool scaffold and dev config (#163) and the Moss group context (#164) are implemented. NDOs as assets (#165) follows. Supersedes the "Moss Integration Contract" in [lobby-architecture.md](lobby-architecture.md) §9 where they differ.
44

55
Moss is the Weave host (lightningrodlabs/moss). In the v0.1.0 pilot Nondominium runs as a **tool** inside Moss, started with the `weave` CLI in dev mode. The standalone build (Lobby DNA, Group DNA, invite codes) keeps compiling and running unchanged; the pilot ships Moss only.
66

@@ -55,7 +55,26 @@ hc-connect.ts connectHolochainClient()
5555

5656
Moss mode is a connection mode (`HolochainConnectionMode` gains `'moss'`). The cross-group view is not implemented: a cross-group render throws.
5757

58+
## The Moss group context (#164)
59+
60+
Inside Moss the standalone Lobby model is replaced behind one seam, `LobbyServiceResolved` in `lobby.service.ts`: it is `MossLobbyServiceLive` when the page is hosted by Moss and `LobbyServiceLive` otherwise. Stores, `NdoService` and components see the same `LobbyService` interface in both.
61+
62+
| Concern | Standalone | Inside Moss |
63+
|---|---|---|
64+
| Groups | Lobby DNA announces clones of the Group DNA | One group: the tool instance's provisioned `group` cell (`MOSS_GROUP_ID = 'moss'`) |
65+
| Joining | Invite code or link | Automatic: the tool installer (`toolInstaller`) creates the `GroupProfile`, named after the Moss group; every other member waits for it to gossip, then calls `join_group` |
66+
| Profile | `LobbyUserProfile` form, localStorage | `profilesClient`, mapped to the same `LobbyUserProfile` shape |
67+
| Role | none | `myAccountabilitiesPerGroup()`, strongest of Member, Steward, Progenitor, shown in the sidebar |
68+
| NDO clone | `AppWebsocket.createCloneCell` | `WeaveClient.createCloneCell(req, PUBLIC_NDO_CLONES)` |
69+
| Discovery of NDOs | `NdoAnchor` in the group cell | same |
70+
71+
Create group, join group and invite links are hidden in Moss (`mossStore.isMoss`), and the service refuses them with `Groups are managed by Moss`. The Lobby DHT calls answer with nothing: every tool instance is its own network, so there is no shared lobby to announce to.
72+
73+
Code: `ui/src/lib/moss/` (`group-cell.ts`, `profile.ts`, `ndo-clone.ts`), `ui/src/lib/services/zomes/moss-lobby.ts` (the service, with injected dependencies so it runs in a unit test against a mocked WeaveClient) and `ui/src/lib/stores/moss.store.svelte.ts`.
74+
75+
**NDO clones are not public to the Moss group (`PUBLIC_NDO_CLONES = false`).** `createCloneCell(req, true)` registers the clone in the Moss group DNA so other members and always-online nodes can install it. Moss `0.16.0-rc.5` cannot register a clone that carries DNA properties: `join_cloned_cell` declares `properties: Option<SerializedBytes>` and the renderer forwards the properties object untouched, so the call fails with a Deserialize error and leaves an orphan cell. NDO clones need their properties (ADR-013). Discovery does not depend on the registration, since peers re-derive the clone from the `NdoAnchor` in the group cell, but an always-online node (wdocker) will not host the NDO cells until this flips. Flip `PUBLIC_NDO_CLONES` once Moss accepts properties.
76+
5877
## Open items
5978

60-
- `weaveClient.createCloneCell(req, true)` fails whenever `req.modifiers.properties` is set, which NDO clones need. See the #162 result and #164.
79+
- `weaveClient.createCloneCell(req, true)` fails whenever `req.modifiers.properties` is set, which NDO clones need (see above and the #162 result).
6180
- `AppletServices` in `@theweave/api` `0.7.0-rc.3` has `getAssetInfo`, `search` and `creatables`. Opening an asset is not a method on it: Moss renders the tool with an `asset` view (`renderInfo.view.type === 'asset'`), and the tool calls `weaveClient.openAsset` to open other assets.

‎documentation/specifications/ui_architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ On top of this hierarchy, the **next** UI iteration adds a cross-cutting **Persp
102102

103103
Dependencies only point down: routes render components, components read stores, stores call services, services call the Holochain client. The domain layer sits beside the services and is imported by stores and components. The three rules that keep it that way are enforced by a script (see [§17](#17-architecture-invariants)).
104104

105-
**Moss host (`lib/moss/`).** When the UI runs inside Moss it is a tool, not a page that owns its conductor connection. `lib/moss/` holds that seam as plain TypeScript beside the service layer: `host.ts` (pure host detection), `connect.ts` (the `WeaveClient` connection, injectable for tests) and `applet-services.ts` (what the tool tells Moss about itself). Only `utils/hc-connect.ts` imports it, as one more connection mode (§15.4), so components, stores and services see the same `AppClient` whether the host is Moss or a standalone conductor. Details in [moss-integration.md](moss-integration.md).
105+
**Moss host (`lib/moss/`).** When the UI runs inside Moss it is a tool, not a page that owns its conductor connection. `lib/moss/` holds that seam as plain TypeScript beside the service layer: `host.ts` (pure host detection), `connect.ts` (the `WeaveClient` connection, injectable for tests) and `applet-services.ts` (what the tool tells Moss about itself). Only `utils/hc-connect.ts` imports it, as one more connection mode (§15.4), so components, stores and services see the same `AppClient` whether the host is Moss or a standalone conductor. The group context has one more seam: `LobbyServiceResolved` selects `MossLobbyServiceLive` (one group, the tool instance's `group` cell) or the Lobby and Group DNA implementation, behind the same `LobbyService` interface, and `moss.store.svelte.ts` holds the Moss profile and role for components. Details in [moss-integration.md](moss-integration.md).
106106

107107
---
108108

‎ui/src/lib/components/group/GroupView.svelte‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { appContext } from '$lib/stores/app.context.svelte';
1010
import { groupStore } from '$lib/stores/group.store.svelte';
1111
import { lobbyStore } from '$lib/stores/lobby.store.svelte';
12+
import { mossStore } from '$lib/stores/moss.store.svelte';
1213
import { devStorageKey } from '$lib/utils/hc-connect';
1314
import type { ActiveFilters } from '$lib/stores/lobby.store.svelte';
1415
import NdoBrowser from '$lib/components/lobby/NdoBrowser.svelte';
@@ -102,7 +103,8 @@
102103
appContext.currentView = 'group';
103104
appContext.selectedGroupId = groupId;
104105
void groupStore.loadGroupData(groupId);
105-
if (!hasVisited(groupId)) {
106+
// Inside Moss the profile and who sees it are the Moss group's business.
107+
if (!mossStore.isMoss && !hasVisited(groupId)) {
106108
showProfileModal = true;
107109
}
108110
});
@@ -183,13 +185,15 @@
183185
<p class="mt-1 font-mono text-sm text-gray-400">{groupId}</p>
184186
</div>
185187
<div class="flex items-center gap-2">
186-
<button
187-
type="button"
188-
onclick={copyInviteLink}
189-
class="rounded border border-gray-300 px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
190-
>
191-
{inviteCopied ? 'Invite link copied!' : 'Copy invite link'}
192-
</button>
188+
{#if !mossStore.isMoss}
189+
<button
190+
type="button"
191+
onclick={copyInviteLink}
192+
class="rounded border border-gray-300 px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
193+
>
194+
{inviteCopied ? 'Invite link copied!' : 'Copy invite link'}
195+
</button>
196+
{/if}
193197
<button
194198
type="button"
195199
onclick={() => {

‎ui/src/lib/components/lobby/LobbyProfileBar.svelte‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import { appContext } from '$lib/stores/app.context.svelte';
3+
import { mossStore } from '$lib/stores/moss.store.svelte';
34
45
interface Props {
56
/** Open the profile setup / edit modal */
@@ -18,14 +19,17 @@
1819
<span class="text-sm text-gray-700">
1920
Signed in as <span class="font-semibold text-gray-900">{profile.nickname}</span>
2021
</span>
21-
<button
22-
type="button"
23-
aria-label="Edit profile"
24-
onclick={() => onOpenProfile()}
25-
class="rounded px-2 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50"
26-
>
27-
Edit
28-
</button>
22+
<!-- Inside Moss the profile belongs to the Moss group and is edited there. -->
23+
{#if !mossStore.isMoss}
24+
<button
25+
type="button"
26+
aria-label="Edit profile"
27+
onclick={() => onOpenProfile()}
28+
class="rounded px-2 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50"
29+
>
30+
Edit
31+
</button>
32+
{/if}
2933
{:else}
3034
<span class="text-sm text-gray-500">No Lobby profile yet</span>
3135
<button

‎ui/src/lib/components/lobby/NdoBrowser.svelte‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
ResourceNature
77
} from '@nondominium/shared-types';
88
import type { ActiveFilters } from '$lib/stores/lobby.store.svelte';
9+
import { mossStore } from '$lib/stores/moss.store.svelte';
910
import NdoCard from './NdoCard.svelte';
1011
1112
interface Props {
@@ -224,7 +225,11 @@
224225
Loading NDOs…
225226
</div>
226227
{:else if descriptors.length === 0 && !errorMessage}
227-
{#if showOnboarding && !hasGroups}
228+
{#if showOnboarding && !hasGroups && mossStore.isMoss}
229+
<p class="text-sm text-gray-500">
230+
Waiting for the Moss group to sync. NDOs appear here once it has.
231+
</p>
232+
{:else if showOnboarding && !hasGroups}
228233
<div class="rounded-lg border border-dashed border-blue-200 bg-blue-50 p-6 text-center">
229234
<p class="text-sm font-medium text-gray-800">Create or join a group to see NDOs</p>
230235
<p class="mt-1 text-sm text-gray-500">

‎ui/src/lib/components/shell/Sidebar.svelte‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { goto } from '$app/navigation';
44
import { lobbyStore } from '$lib/stores/lobby.store.svelte';
55
import { appContext } from '$lib/stores/app.context.svelte';
6+
import { mossStore } from '$lib/stores/moss.store.svelte';
67
import UserProfileForm from '$lib/components/lobby/UserProfileForm.svelte';
78
89
const isActive = (href: string) =>
@@ -138,21 +139,27 @@
138139
>
139140
{g.name}
140141
</a>
141-
<button
142-
type="button"
143-
title="Copy invite link"
144-
onclick={(e) => copyInviteLink(g.id, e)}
145-
class="shrink-0 rounded px-1.5 py-0.5 text-xs text-gray-400 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-white hover:text-blue-600"
146-
>
147-
{copiedGroupId === g.id ? '✓' : '⎘'}
148-
</button>
142+
{#if !mossStore.isMoss}
143+
<button
144+
type="button"
145+
title="Copy invite link"
146+
onclick={(e) => copyInviteLink(g.id, e)}
147+
class="shrink-0 rounded px-1.5 py-0.5 text-xs text-gray-400 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-white hover:text-blue-600"
148+
>
149+
{copiedGroupId === g.id ? '✓' : '⎘'}
150+
</button>
151+
{/if}
149152
</li>
150153
{/each}
151154
</ul>
152155
{:else}
153-
<p class="mb-2 text-xs text-gray-400 italic">No groups yet.</p>
156+
<p class="mb-2 text-xs text-gray-400 italic">
157+
{mossStore.isMoss ? 'Waiting for the Moss group to sync…' : 'No groups yet.'}
158+
</p>
154159
{/if}
155160

161+
<!-- Inside Moss the Moss group is the group: no creating, joining or invite codes. -->
162+
{#if !mossStore.isMoss}
156163
<!-- Create Group -->
157164
{#if showCreateForm}
158165
<div class="mb-2 rounded border border-gray-200 bg-white p-2">
@@ -255,8 +262,19 @@
255262
</button>
256263
{/if}
257264

265+
{/if}
266+
258267
<!-- My Profile -->
259268
<div class="mt-auto border-t border-gray-200 pt-3">
269+
{#if mossStore.isMoss}
270+
<!-- The profile and the role belong to the Moss group; edit them in Moss. -->
271+
<p class="px-2 py-1.5 text-xs text-gray-500">
272+
<span class="font-medium text-gray-700">{appContext.lobbyUserProfile?.nickname ?? 'Member'}</span>
273+
{#if mossStore.role}
274+
<span class="ml-1 text-gray-400">· {mossStore.role}</span>
275+
{/if}
276+
</p>
277+
{:else}
260278
<button
261279
type="button"
262280
onclick={() => {
@@ -271,5 +289,6 @@
271289
Set up profile
272290
{/if}
273291
</button>
292+
{/if}
274293
</div>
275294
</nav>

‎ui/src/lib/moss/group-cell.spec.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { CellType, type AppInfo } from '@holochain/client';
3+
import { MOSS_GROUP_ID, getMossGroupCell } from './group-cell';
4+
5+
const dna = new Uint8Array([1, 2, 3]);
6+
const agent = new Uint8Array([9, 9, 9]);
7+
8+
function appInfo(cellInfo: Record<string, unknown[]>): AppInfo {
9+
return { cell_info: cellInfo } as unknown as AppInfo;
10+
}
11+
12+
describe('getMossGroupCell', () => {
13+
it('returns the provisioned group cell Moss installs for the tool instance', () => {
14+
const cell = getMossGroupCell(
15+
appInfo({
16+
group: [
17+
{
18+
type: CellType.Provisioned,
19+
value: { cell_id: [dna, agent], dna_modifiers: { network_seed: 'instance-seed' } }
20+
}
21+
]
22+
})
23+
);
24+
expect(cell).toEqual({
25+
networkSeed: 'instance-seed',
26+
dnaHash: dna,
27+
cellId: [dna, agent],
28+
cloneId: 'group',
29+
enabled: true
30+
});
31+
});
32+
33+
it('ignores cloned group cells, which belong to the standalone model', () => {
34+
const cell = getMossGroupCell(
35+
appInfo({
36+
group: [
37+
{ type: CellType.Cloned, value: { cell_id: [dna, agent], dna_modifiers: { network_seed: 'x' } } }
38+
]
39+
})
40+
);
41+
expect(cell).toBeNull();
42+
});
43+
44+
it('falls back to the group id when the cell carries no network seed', () => {
45+
const cell = getMossGroupCell(
46+
appInfo({ group: [{ type: CellType.Provisioned, value: { cell_id: [dna, agent], dna_modifiers: {} } }] })
47+
);
48+
expect(cell?.networkSeed).toBe(MOSS_GROUP_ID);
49+
});
50+
51+
it('returns null when the app has no group role or no app info', () => {
52+
expect(getMossGroupCell(appInfo({}))).toBeNull();
53+
expect(getMossGroupCell(null)).toBeNull();
54+
});
55+
});

‎ui/src/lib/moss/group-cell.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { CellType, type AppInfo, type CellInfo } from '@holochain/client';
2+
import type { GroupCellInfo } from '../services/group-clone.helpers';
3+
4+
/**
5+
* Inside Moss there is exactly one Nondominium group: the tool instance's own
6+
* `group` cell. This is its id wherever the standalone UI would use the clone's
7+
* network seed.
8+
*/
9+
export const MOSS_GROUP_ID = 'moss';
10+
11+
/**
12+
* The `group` cell Moss installs for the tool instance. Moss provisions every
13+
* role of the manifest, deferred ones included (verified in the #162 spike), so
14+
* unlike the standalone model this cell is provisioned, not cloned.
15+
*/
16+
export function getMossGroupCell(appInfo: AppInfo | null): GroupCellInfo | null {
17+
const cells: CellInfo[] = appInfo?.cell_info?.['group'] ?? [];
18+
for (const cellInfo of cells) {
19+
if (cellInfo.type !== CellType.Provisioned) continue;
20+
const { cell_id, dna_modifiers } = cellInfo.value;
21+
const seed = dna_modifiers?.network_seed;
22+
return {
23+
networkSeed: typeof seed === 'string' && seed ? seed : MOSS_GROUP_ID,
24+
dnaHash: cell_id[0],
25+
cellId: cell_id,
26+
cloneId: 'group',
27+
enabled: true
28+
};
29+
}
30+
return null;
31+
}

0 commit comments

Comments
 (0)