@teamscala/service-boot 0.23.378 → 0.23.380

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.378",
9
+ "version": "0.23.380",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -86,3 +86,65 @@ describe("composePageTitle", () => {
86
86
  expect(composePageTitle({ websiteName: "" })).toBe("Untitled");
87
87
  });
88
88
  });
89
+
90
+ describe("composePageTitle with orgPrefix (feature flag page-title-org-prefix)", () => {
91
+ test("prefix is the owning org's name, never the internal website label", () => {
92
+ // The defect: client-facing document tabs read "Public Pages: <subject>".
93
+ expect(
94
+ composePageTitle(
95
+ {
96
+ websiteName: "Public Pages",
97
+ organisationName: "GBG Indo",
98
+ entityTitle: "Launcha Blueprint",
99
+ },
100
+ { orgPrefix: true },
101
+ ),
102
+ ).toBe("GBG Indo: Launcha Blueprint");
103
+ });
104
+
105
+ test("org with no display name gets NO prefix — never the website name", () => {
106
+ expect(
107
+ composePageTitle(
108
+ { websiteName: "Public Pages", organisationName: "", entityTitle: "Launcha Blueprint" },
109
+ { orgPrefix: true },
110
+ ),
111
+ ).toBe("Launcha Blueprint");
112
+ expect(
113
+ composePageTitle({ websiteName: "Public Pages", entityTitle: "Launcha Blueprint" }, { orgPrefix: true }),
114
+ ).toBe("Launcha Blueprint");
115
+ });
116
+
117
+ test("per-page override and curated template still win over the org prefix", () => {
118
+ expect(
119
+ composePageTitle(
120
+ { pageMetaTitle: "Override", organisationName: "GBG Indo", pageName: "P" },
121
+ { orgPrefix: true },
122
+ ),
123
+ ).toBe("Override");
124
+ expect(
125
+ composePageTitle(
126
+ { templateTitle: "T", organisationName: "GBG Indo", pageName: "P" },
127
+ { orgPrefix: true },
128
+ ),
129
+ ).toBe("T");
130
+ });
131
+
132
+ test("no subject renders the org name alone; nothing at all is 'Untitled'", () => {
133
+ expect(composePageTitle({ organisationName: "GBG Indo" }, { orgPrefix: true })).toBe(
134
+ "GBG Indo",
135
+ );
136
+ expect(composePageTitle({}, { orgPrefix: true })).toBe("Untitled");
137
+ });
138
+
139
+ test("flag off keeps the legacy website-name derivation", () => {
140
+ expect(
141
+ composePageTitle(
142
+ { websiteName: "Public Pages", organisationName: "GBG Indo", pageName: "Dashboard" },
143
+ { orgPrefix: false },
144
+ ),
145
+ ).toBe("Public Pages: Dashboard");
146
+ expect(
147
+ composePageTitle({ websiteName: "Public Pages", organisationName: "GBG Indo", pageName: "Dashboard" }),
148
+ ).toBe("Public Pages: Dashboard");
149
+ });
150
+ });
@@ -32,8 +32,13 @@ export interface TitleContext {
32
32
  pageMetaTitle?: string | null;
33
33
  /** Resolved resource-template string (forms/quiz/gpt). Curated per-surface shape. */
34
34
  templateTitle?: string | null;
35
- /** builda_websites.name — the surface, the primary site context. */
35
+ /** builda_websites.name — the surface, the legacy site context (orgPrefix off). */
36
36
  websiteName?: string | null;
37
+ /** The OWNING ORG's display name (brand.companyName, resolved from the
38
+ * org the page's brand_org_path names). The title prefix when orgPrefix is
39
+ * on — operator ruling 2026-08-17: a client-facing document's tab carries
40
+ * the org's name, never the internal website label ("Public Pages"). */
41
+ organisationName?: string | null;
37
42
  /** builda_pages.name — the page (e.g. "Dashboard"). */
38
43
  pageName?: string | null;
39
44
  /** Resource entity title (proposal/form/quiz/agent name) — preferred subject. */
@@ -46,20 +51,35 @@ function trimToDefined(value: unknown): string | undefined {
46
51
  return trimmed ? trimmed : undefined;
47
52
  }
48
53
 
49
- export function composePageTitle(ctx: TitleContext): string {
54
+ export function composePageTitle(
55
+ ctx: TitleContext,
56
+ opts?: { orgPrefix?: boolean },
57
+ ): string {
50
58
  // 1. Explicit per-page override wins outright — the operator's escape
51
- // hatch for a custom SEO/social title. It is NEVER the default source.
59
+ // hatch for a custom SEO/social title. It is NEVER the default source.
52
60
  const override = trimToDefined(ctx.pageMetaTitle);
53
61
  if (override) return override;
54
62
 
55
63
  // 2. Curated resource template (forms/quiz/gpt). Already contextual
56
- // (entity + org), so it beats the generic derivation for those surfaces.
64
+ // (entity + org), so it beats the generic derivation for those surfaces.
57
65
  const template = trimToDefined(ctx.templateTitle);
58
66
  if (template) return template;
59
67
 
60
- // 3. Default derivation: <website.name>: <subject>. Site-first so the
61
- // stable surface precedes the varying page — how a tab is scanned.
68
+ // 3. Default derivation. Site-first so the stable surface precedes the
69
+ // varying page — how a tab is scanned. With orgPrefix on (feature flag
70
+ // page-title-org-prefix), the prefix is the OWNING ORG's name, never the
71
+ // website label: an internal site name ("Public Pages") on a
72
+ // client-facing document is the defect the flag exists to remove. An org
73
+ // with no display name gets NO prefix — falling back to the website name
74
+ // would reintroduce the defect the mode exists to fix.
62
75
  const subject = trimToDefined(ctx.entityTitle) ?? trimToDefined(ctx.pageName);
76
+ if (opts?.orgPrefix) {
77
+ const orgName = trimToDefined(ctx.organisationName);
78
+ if (orgName && subject) return `${orgName}: ${subject}`;
79
+ if (orgName) return orgName;
80
+ if (subject) return subject;
81
+ return "Untitled";
82
+ }
63
83
  const websiteName = trimToDefined(ctx.websiteName);
64
84
  if (websiteName && subject) return `${websiteName}: ${subject}`;
65
85
  if (websiteName) return websiteName;
@@ -19,9 +19,21 @@ import { getAppLogger } from "@teamscala/logger/app-loggers";
19
19
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
20
20
  import { getServerClient } from "./page-server-orpc/server-client.ts";
21
21
  import * as v from "valibot";
22
+ import type { BlockSchemaKey } from "@teamscala/db/registry/generated/block-schema";
22
23
 
23
24
  const AgentConfigSchema = v.object({ agentId: v.string() });
24
25
 
26
+ // THE PUSHED BLOCK NAME IS AN IMPORTED IDENTITY, NEVER A SPELLED STRING.
27
+ // discover-blocks keys the Puck registry off block_schema.slug alone
28
+ // (`components[row.slug] = …`), so a type that is not a live slug resolves to
29
+ // nothing — and because injection is fail-soft, the page renders unchanged with
30
+ // no error. That is exactly how the bubble went missing from every gated
31
+ // proposal and agreement: the block was consolidated to ResourceLauncher and
32
+ // these injectors kept pushing their pre-consolidation names. BlockSchemaKey is
33
+ // the generated union of live slugs, so annotating it here makes a stale name a
34
+ // COMPILE error instead of a silent absence (identity-is-imported-never-spelled).
35
+ const LAUNCHER_BLOCK: BlockSchemaKey = "ResourceLauncher";
36
+
25
37
  /** Serialise the entire page content tree as text for the GPT context. */
26
38
  async function buildProposalContext(resolved: Record<string, unknown>): Promise<string> {
27
39
  const proposal = resolved.proposal as Record<string, unknown> | undefined;
@@ -119,7 +131,7 @@ export async function injectProposalChatLauncher(
119
131
  try {
120
132
  const runtimeContext = await buildProposalContext(resolved);
121
133
  (page.content as Array<Record<string, unknown>>).push({
122
- type: "ChatLauncherBlock",
134
+ type: LAUNCHER_BLOCK,
123
135
  props: {
124
136
  id: "proposal-chat-launcher",
125
137
  agentId,
@@ -4,11 +4,16 @@
4
4
  * @edit edit directly
5
5
  *
6
6
  * Meta/title helpers for the route registrar: interpolate the route's
7
- * meta_context against the resolved graph, and resolve a curated
8
- * page_meta_template title. Extracted from register-registry-page-route.ts.
7
+ * meta_context against the resolved graph. Extracted from
8
+ * register-registry-page-route.ts.
9
+ *
10
+ * resolveTemplateTitle (the page_meta_template title reader) was DELETED
11
+ * 2026-08-17: it only ever applied when NO resource entity resolved, and on a
12
+ * resource page an entity ALWAYS resolves — the curated template was discarded
13
+ * by construction, dead on exactly the pages the type exists for (Valencia's
14
+ * finding). composePageTitle (+ the org-prefix flag) owns titles end to end.
9
15
  */
10
16
  import { interpolateMeta } from "../resolve-from-loader-config.ts";
11
- import { getEntriesByType } from "@teamscala/db/registry/accessors";
12
17
 
13
18
  export function buildMetaFromContext(
14
19
  metaContext: Record<string, Record<string, string>> | undefined,
@@ -18,74 +23,6 @@ export function buildMetaFromContext(
18
23
  return interpolateMeta(metaContext as Record<string, unknown>, resolved);
19
24
  }
20
25
 
21
- export function resolveTemplateTitle(
22
- service: string,
23
- resolved: Record<string, unknown>,
24
- ): string | undefined {
25
- const rows = getEntriesByType("page_meta_template") as Array<{
26
- slug: string;
27
- config: Record<string, unknown>;
28
- }>;
29
- const bySlug = new Map<string, Record<string, unknown>>();
30
- for (const row of rows) {
31
- bySlug.set(row.slug, row.config);
32
- }
33
- const merged = (bySlug.get("default") ?? null) as Record<string, unknown> | null;
34
- const serviceTmpl = (bySlug.get(service) ?? null) as Record<string, unknown> | null;
35
- const tmpl = serviceTmpl ?? merged;
36
- if (!tmpl) return undefined;
37
- const titleTemplate = tmpl.titleTemplate as string | undefined;
38
- if (!titleTemplate) return undefined;
39
- const brand = (resolved.brand as Record<string, unknown>) ?? {};
40
- const website = (resolved.website as Record<string, unknown>) ?? {};
41
- const page = (resolved.page as Record<string, unknown>) ?? {};
42
- // The full placeholder set the curated resource templates use:
43
- // {org.name}, {website.name}, {page.name}, {form.name}, {quiz.name},
44
- // {proposal.name}, {agent.name}. Previously only {org.name} resolved, so
45
- // every resource template ("{agent.name} — {org.name}", "{form.name} by
46
- // {org.name}") shipped with a blank subject. Resolving the graph here is
47
- // the executor half of composePageTitle (business-logic-is-rows-plus-
48
- // executors): the template ROWS carry the shape, this carries the values.
49
- const context: Record<string, unknown> = {
50
- org: { name: (brand.companyName as string) || undefined },
51
- website: { name: (website.name as string) || undefined },
52
- page: { name: (page.name as string) || undefined },
53
- form: { name: ((resolved.form as Record<string, unknown>)?.name as string) || undefined },
54
- // An assessment has no `name` column — it carries `title`, and a report
55
- // surface carries `report_title` ("Personalised Authority Blueprint")
56
- // which is the document's own name and the right subject for its tab.
57
- // Reading `.name` here resolved to "", so the template produced " by
58
- // <org>", was discarded as empty, and EVERY quiz page silently fell back
59
- // to the "<website>: <subject>" derivation — surfacing the internal site
60
- // name ("Public Pages") on a client-facing document. The failure was
61
- // invisible because the fallback is itself a plausible title.
62
- quiz: {
63
- name:
64
- ((resolved.quiz as Record<string, unknown>)?.report_title as string) ||
65
- ((resolved.quiz as Record<string, unknown>)?.title as string) ||
66
- ((resolved.quiz as Record<string, unknown>)?.name as string) ||
67
- undefined,
68
- },
69
- proposal: {
70
- name: ((resolved.proposal as Record<string, unknown>)?.name as string) || undefined,
71
- },
72
- agent: { name: ((resolved.agent as Record<string, unknown>)?.name as string) || undefined },
73
- };
74
- const result = titleTemplate.replace(/\{([^}]+)\}/g, (_m, path: string) => {
75
- const keys = path.trim().split(".");
76
- let cur: unknown = context;
77
- for (const k of keys) {
78
- if (cur && typeof cur === "object") {
79
- cur = (cur as Record<string, unknown>)[k];
80
- } else {
81
- return "";
82
- }
83
- }
84
- return cur === null || cur === undefined ? "" : String(cur);
85
- });
86
- return result || undefined;
87
- }
88
-
89
26
  /**
90
27
  * THE ONE SUBJECT DERIVATION. What a page is ABOUT — the entity whose name the
91
28
  * browser tab should carry.
@@ -91,6 +91,9 @@ export function resolvedToPageData(
91
91
  // a page framed inside another shell (e.g. a form in a GPT tool panel)
92
92
  // matches the host's light/dark scheme so an embedded page doesn't flash.
93
93
  themeOverride?: "light" | "dark",
94
+ // feature flag page-title-org-prefix (read live by the caller): title
95
+ // prefixes carry the OWNING ORG's name instead of the website label.
96
+ orgPrefix?: boolean,
94
97
  ): Record<string, unknown> {
95
98
  const brand = (resolved.brand as Record<string, unknown>) ?? {};
96
99
  const primaryColor = (brand.primaryColor as string) ?? "";
@@ -158,11 +161,15 @@ export function resolvedToPageData(
158
161
  favicon: (brand.favicon as string) ?? null,
159
162
  },
160
163
  meta: {
161
- title: composePageTitle({
162
- templateTitle: (meta.title as string) || undefined,
163
- websiteName: (website.name as string) || undefined,
164
- entityTitle: entityTitle || undefined,
165
- }),
164
+ title: composePageTitle(
165
+ {
166
+ templateTitle: (meta.title as string) || undefined,
167
+ websiteName: (website.name as string) || undefined,
168
+ organisationName: (brand.companyName as string) || undefined,
169
+ entityTitle: entityTitle || undefined,
170
+ },
171
+ { orgPrefix },
172
+ ),
166
173
  },
167
174
  };
168
175
  }
@@ -226,12 +233,16 @@ export function resolvedToPageData(
226
233
  },
227
234
  theme,
228
235
  meta: {
229
- title: composePageTitle({
236
+ title: composePageTitle(
237
+ {
230
238
  templateTitle: (meta.title as string) || undefined,
231
239
  websiteName: (website.name as string) || undefined,
232
- pageName: contentPageName,
233
- entityTitle: entityTitle || undefined,
234
- }),
240
+ organisationName: (brand.companyName as string) || undefined,
241
+ pageName: contentPageName,
242
+ entityTitle: entityTitle || undefined,
243
+ },
244
+ { orgPrefix },
245
+ ),
235
246
  },
236
247
  };
237
248
  }
@@ -359,13 +370,17 @@ export function resolvedToPageData(
359
370
  },
360
371
  theme,
361
372
  meta: {
362
- title: composePageTitle({
363
- pageMetaTitle: bodyMetaTitle,
364
- templateTitle: (meta.title as string) || undefined,
365
- websiteName: (website.name as string) || undefined,
366
- pageName: pageName ?? pageRootTitle,
367
- entityTitle: entityTitle || undefined,
368
- }),
373
+ title: composePageTitle(
374
+ {
375
+ pageMetaTitle: bodyMetaTitle,
376
+ templateTitle: (meta.title as string) || undefined,
377
+ websiteName: (website.name as string) || undefined,
378
+ organisationName: (brand.companyName as string) || undefined,
379
+ pageName: pageName ?? pageRootTitle,
380
+ entityTitle: entityTitle || undefined,
381
+ },
382
+ { orgPrefix },
383
+ ),
369
384
  ...(pageDescription ? { description: pageDescription } : {}),
370
385
  },
371
386
  };
@@ -462,13 +477,17 @@ export function resolvedToPageData(
462
477
  ...(pageDescription ? { description: pageDescription } : {}),
463
478
  ...meta,
464
479
  };
465
- mergedMeta.title = composePageTitle({
466
- pageMetaTitle,
467
- templateTitle: (meta.title as string) || undefined,
468
- websiteName: (website.name as string) || undefined,
469
- pageName: pageName ?? ((pageRoot?.title as string) || undefined),
470
- entityTitle: entityTitle || undefined,
471
- });
480
+ mergedMeta.title = composePageTitle(
481
+ {
482
+ pageMetaTitle,
483
+ templateTitle: (meta.title as string) || undefined,
484
+ websiteName: (website.name as string) || undefined,
485
+ organisationName: (brand.companyName as string) || undefined,
486
+ pageName: pageName ?? ((pageRoot?.title as string) || undefined),
487
+ entityTitle: entityTitle || undefined,
488
+ },
489
+ { orgPrefix },
490
+ );
472
491
 
473
492
  return {
474
493
  puckData,
@@ -15,6 +15,7 @@
15
15
  import type { RenderModeLiteral } from "./page-route-shell-types.ts";
16
16
  import { PageRouteConfigSchema } from "@teamscala/db-validation/registry-schemas/page-route";
17
17
  import { ResourceLoaderConfigSchema } from "@teamscala/db-validation/registry-schemas/resource-loader";
18
+ import { FeatureFlagConfigSchema } from "@teamscala/db-validation/registry-schemas/feature-flag";
18
19
  import {
19
20
  loadRegistryConfig,
20
21
  loadRegistryConfigsByType,
@@ -43,7 +44,6 @@ import {
43
44
  import { resolvedToPageData } from "./register-registry-page-route/resolve-page-data.ts";
44
45
  import {
45
46
  buildMetaFromContext,
46
- resolveTemplateTitle,
47
47
  } from "./register-registry-page-route/meta-title.ts";
48
48
  import { prefetchAiChatSession } from "./register-registry-page-route/ai-chat-prefetch.ts";
49
49
  import {
@@ -479,16 +479,24 @@ export async function registerRegistryPageRoute(
479
479
  ) {
480
480
  meta.title = pageMetaTitle.trim();
481
481
  }
482
- const templateTitle = resolveTemplateTitle(serviceSlug, resolved);
483
- if (templateTitle && !meta.title) {
484
- const resolvedEntity =
485
- (resolved.agent as { name?: string } | undefined)?.name ||
486
- (resolved.proposal as { name?: string } | undefined)?.name ||
487
- (resolved.form as { name?: string } | undefined)?.name ||
488
- (resolved.quiz as { title?: string } | undefined)?.title;
489
- if (!resolvedEntity) {
490
- meta.title = templateTitle;
491
- }
482
+ // Org-prefixed page titles (feature flag page-title-org-prefix, read
483
+ // LIVE per request — feature flags are never cached). The title prefix
484
+ // is the OWNING ORG's name, never the internal website label
485
+ // ("Public Pages: <subject>" on a client-facing document was the
486
+ // defect; operator ruling 2026-08-17). Absent or unreadable flag ⇒
487
+ // legacy <website.name>: <subject> derivation — the switch fails toward
488
+ // the pre-flag behaviour, and flipping it back is one registry edit.
489
+ let orgTitlePrefix = false;
490
+ try {
491
+ orgTitlePrefix = (
492
+ await loadRegistryConfig(
493
+ "feature_flag",
494
+ "page-title-org-prefix",
495
+ FeatureFlagConfigSchema,
496
+ )
497
+ ).enabled;
498
+ } catch {
499
+ // Flag row absent — legacy derivation.
492
500
  }
493
501
  const regionSlots = (
494
502
  loaderConfig as v.InferOutput<typeof ResourceLoaderConfigSchema> & {
@@ -515,6 +523,7 @@ export async function registerRegistryPageRoute(
515
523
  routeProviders !== baseProviders ? routeProviders : undefined,
516
524
  regionSlots,
517
525
  themeOverride,
526
+ orgTitlePrefix,
518
527
  );
519
528
  const panelSlug = new URL(request.url).searchParams.get("panel");
520
529
  if (panelSlug) {
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Regression test for the block NAME the chat injectors push.
7
+ *
8
+ * THE DEFECT THIS PINS (found 2026-08-19): the floating assistant bubble was
9
+ * silently absent on every gated proposal and agreement. discover-blocks keys
10
+ * the Puck component registry off `block_schema.slug` and nothing else
11
+ * (`components[row.slug] = …`), so a pushed block `type` must EQUAL an active
12
+ * slug. The launcher block was consolidated to the single `ResourceLauncher`
13
+ * row, but the injectors still pushed the pre-consolidation names —
14
+ * `ResourceChatLauncher` here and `ChatLauncherBlock` in proposal-chat-inject.
15
+ * Neither resolves, and because injection is deliberately fail-soft the page
16
+ * rendered unchanged with no error: the classic shape of
17
+ * `a-deleted-producer-leaves-its-consumers-executing`.
18
+ *
19
+ * Asserting the literal is the WEAK half of the fix — it catches a regression
20
+ * of this exact string, not a future rename of the block. The strong half is
21
+ * single-sourcing the name so it cannot be spelled at all
22
+ * (`identity-is-imported-never-spelled`); until that lands, this test is what
23
+ * stands between the bubble and another silent disappearance.
24
+ */
25
+
26
+ import { describe, expect, mock, test } from "bun:test";
27
+
28
+ /** The ONE active launcher block_schema slug — the Puck registry key. */
29
+ const REGISTERED_LAUNCHER_SLUG = "ResourceLauncher";
30
+
31
+ mock.module("@teamscala/db/registry/load-config", () => ({
32
+ loadRegistryConfig: async () => ({ agentId: "agent-1" }),
33
+ }));
34
+ mock.module("./page-server-orpc/server-client.ts", () => ({
35
+ getServerClient: () => ({}),
36
+ }));
37
+ mock.module("@teamscala/logger/app-loggers", () => ({
38
+ getAppLogger: () => ({ error: () => {} }),
39
+ }));
40
+
41
+ const { injectResourceChatLauncher } = await import("./resource-chat-inject.ts");
42
+ const { injectProposalChatLauncher } = await import("./proposal-chat-inject.ts");
43
+
44
+ function makeResolved(resourceKey: string): Record<string, unknown> {
45
+ return {
46
+ [resourceKey]: { organisation_id: "org-1", name: "Demo" },
47
+ brand: { primaryColor: "#123456" },
48
+ page: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
49
+ };
50
+ }
51
+
52
+ function pushedBlocks(resolved: Record<string, unknown>) {
53
+ const pd = (resolved.page as { puck_data: { content: Array<{ type: string }> } }).puck_data;
54
+ return pd.content;
55
+ }
56
+
57
+ describe("chat injectors — pushed block name resolves to a registered block", () => {
58
+ test("the generic resource injector pushes the registered launcher slug", async () => {
59
+ const resolved = makeResolved("agreement");
60
+ await injectResourceChatLauncher(resolved, "viewer@example.com", {
61
+ resourceKey: "agreement",
62
+ agentConfigSlug: "agreement-assistant-agent",
63
+ title: "Agreement Assistant",
64
+ launchLabel: "Ask about this agreement",
65
+ });
66
+ const blocks = pushedBlocks(resolved);
67
+ expect(blocks).toHaveLength(1);
68
+ expect(blocks[0].type).toBe(REGISTERED_LAUNCHER_SLUG);
69
+ });
70
+
71
+ test("the proposal injector pushes the registered launcher slug", async () => {
72
+ const resolved = makeResolved("proposal");
73
+ await injectProposalChatLauncher(resolved, "viewer@example.com");
74
+ const blocks = pushedBlocks(resolved);
75
+ expect(blocks).toHaveLength(1);
76
+ expect(blocks[0].type).toBe(REGISTERED_LAUNCHER_SLUG);
77
+ });
78
+
79
+ test("neither injector pushes a pre-consolidation launcher name", async () => {
80
+ const a = makeResolved("agreement");
81
+ await injectResourceChatLauncher(a, "viewer@example.com", {
82
+ resourceKey: "agreement",
83
+ agentConfigSlug: "agreement-assistant-agent",
84
+ title: "Agreement Assistant",
85
+ launchLabel: "Ask about this agreement",
86
+ });
87
+ const p = makeResolved("proposal");
88
+ await injectProposalChatLauncher(p, "viewer@example.com");
89
+
90
+ const dead = ["ResourceChatLauncher", "ChatLauncherBlock"];
91
+ for (const blocks of [pushedBlocks(a), pushedBlocks(p)]) {
92
+ for (const block of blocks) {
93
+ expect(dead).not.toContain(block.type);
94
+ }
95
+ }
96
+ });
97
+ });
@@ -24,15 +24,28 @@ import { getAppLogger } from "@teamscala/logger/app-loggers";
24
24
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
25
25
  import { getServerClient } from "./page-server-orpc/server-client.ts";
26
26
  import * as v from "valibot";
27
+ import type { BlockSchemaKey } from "@teamscala/db/registry/generated/block-schema";
28
+ import type { ConfigKey } from "@teamscala/db/registry/generated/config";
27
29
 
28
30
  const AgentConfigSchema = v.object({ agentId: v.string() });
29
31
 
32
+ // THE PUSHED BLOCK NAME IS AN IMPORTED IDENTITY, NEVER A SPELLED STRING.
33
+ // discover-blocks keys the Puck registry off block_schema.slug alone
34
+ // (`components[row.slug] = …`), so a type that is not a live slug resolves to
35
+ // nothing — and because injection is fail-soft, the page renders unchanged with
36
+ // no error. That is exactly how the bubble went missing from every gated
37
+ // proposal and agreement: the block was consolidated to ResourceLauncher and
38
+ // these injectors kept pushing their pre-consolidation names. BlockSchemaKey is
39
+ // the generated union of live slugs, so annotating it here makes a stale name a
40
+ // COMPILE error instead of a silent absence (identity-is-imported-never-spelled).
41
+ const LAUNCHER_BLOCK: BlockSchemaKey = "ResourceLauncher";
42
+
30
43
  /** Per-resource-type injection config — the only thing that varies. */
31
44
  export interface ResourceChatOptions {
32
45
  /** Key in `resolved` holding the record (e.g. "proposal", "agreement"). */
33
46
  resourceKey: string;
34
47
  /** `config` row slug carrying { agentId } (e.g. "agreement-assistant-agent"). */
35
- agentConfigSlug: string;
48
+ agentConfigSlug: ConfigKey;
36
49
  /** Org id to scope the chat. If omitted, read from resolved[resourceKey].organisation_id
37
50
  * (proposals); agreements pass it explicitly (org isn't on the resolved record). */
38
51
  organisationId?: string;
@@ -145,7 +158,7 @@ export async function injectResourceChatLauncher(
145
158
  try {
146
159
  const runtimeContext = await buildResourceContext(resolved, opts.resourceKey);
147
160
  (page.content as Array<Record<string, unknown>>).push({
148
- type: "ResourceChatLauncher",
161
+ type: LAUNCHER_BLOCK,
149
162
  props: {
150
163
  id: `${opts.resourceKey}-chat-launcher`,
151
164
  agentId,