@aglyn/plugins-ai 1.0.0-beta.238 → 1.0.0-beta.239

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.
Files changed (77) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/ai-job-links.js +8 -0
  3. package/src/lib/components/ai-job-links.js.map +1 -1
  4. package/src/lib/components/ai-jobs-store.d.ts +12 -0
  5. package/src/lib/components/ai-jobs-store.js +15 -0
  6. package/src/lib/components/ai-jobs-store.js.map +1 -1
  7. package/src/lib/components/ai-usage-meter-refresh.d.ts +35 -0
  8. package/src/lib/components/ai-usage-meter-refresh.js +63 -0
  9. package/src/lib/components/ai-usage-meter-refresh.js.map +1 -0
  10. package/src/lib/components/assist-panel.component.js +4 -0
  11. package/src/lib/components/assist-panel.component.js.map +1 -1
  12. package/src/lib/components/use-ai-usage-meter.d.ts +2 -0
  13. package/src/lib/components/use-ai-usage-meter.js +8 -3
  14. package/src/lib/components/use-ai-usage-meter.js.map +1 -1
  15. package/src/lib/jobs/ai-build-unit-outcome.d.ts +3 -1
  16. package/src/lib/jobs/ai-build-unit-outcome.js +19 -3
  17. package/src/lib/jobs/ai-build-unit-outcome.js.map +1 -1
  18. package/src/lib/jobs/ai-job-build-step.js +29 -2
  19. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  20. package/src/lib/jobs/ai-job-draft-ids.js +7 -2
  21. package/src/lib/jobs/ai-job-draft-ids.js.map +1 -1
  22. package/src/lib/jobs/ai-job-form-dataset.d.ts +100 -0
  23. package/src/lib/jobs/ai-job-form-dataset.js +161 -0
  24. package/src/lib/jobs/ai-job-form-dataset.js.map +1 -0
  25. package/src/lib/jobs/ai-job-form-step.d.ts +15 -3
  26. package/src/lib/jobs/ai-job-form-step.js +37 -9
  27. package/src/lib/jobs/ai-job-form-step.js.map +1 -1
  28. package/src/lib/jobs/ai-job-page-language.d.ts +9 -0
  29. package/src/lib/jobs/ai-job-page-language.js +37 -2
  30. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  31. package/src/lib/jobs/ai-job-plan-step.d.ts +18 -0
  32. package/src/lib/jobs/ai-job-plan-step.js +41 -4
  33. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  34. package/src/lib/jobs/ai-job-site-datasets.d.ts +170 -0
  35. package/src/lib/jobs/ai-job-site-datasets.js +499 -0
  36. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -0
  37. package/src/lib/jobs/ai-job-site-step.d.ts +15 -8
  38. package/src/lib/jobs/ai-job-site-step.js +148 -29
  39. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  40. package/src/lib/jobs/fixtures/ai-dataset-site-plan-recordings.d.ts +30 -0
  41. package/src/lib/jobs/fixtures/ai-dataset-site-plan-recordings.js +637 -0
  42. package/src/lib/jobs/fixtures/ai-dataset-site-plan-recordings.js.map +1 -0
  43. package/src/lib/layout-language/ai-layout-compiler.d.ts +10 -0
  44. package/src/lib/layout-language/ai-layout-compiler.js +93 -1
  45. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  46. package/src/lib/layout-language/ai-layout-gaps.js +17 -1
  47. package/src/lib/layout-language/ai-layout-gaps.js.map +1 -1
  48. package/src/lib/layout-language/ai-layout-listings.d.ts +32 -3
  49. package/src/lib/layout-language/ai-layout-listings.js +62 -9
  50. package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
  51. package/src/lib/model/ai-build-job.d.ts +2 -0
  52. package/src/lib/model/ai-build-job.js +16 -1
  53. package/src/lib/model/ai-build-job.js.map +1 -1
  54. package/src/lib/model/ai-build-plan.d.ts +23 -3
  55. package/src/lib/model/ai-build-plan.js +47 -6
  56. package/src/lib/model/ai-build-plan.js.map +1 -1
  57. package/src/lib/model/ai-plan-capabilities.d.ts +7 -0
  58. package/src/lib/model/ai-plan-capabilities.js.map +1 -1
  59. package/src/lib/model/ai-site-job.d.ts +19 -2
  60. package/src/lib/model/ai-site-job.js +21 -6
  61. package/src/lib/model/ai-site-job.js.map +1 -1
  62. package/src/lib/runtime/ai-dataset-generation.d.ts +77 -0
  63. package/src/lib/runtime/ai-dataset-generation.js +127 -0
  64. package/src/lib/runtime/ai-dataset-generation.js.map +1 -0
  65. package/src/lib/runtime/ai-doctrine-validators.d.ts +40 -0
  66. package/src/lib/runtime/ai-doctrine-validators.js +130 -1
  67. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  68. package/src/lib/runtime/ai-doctrine.js +8 -3
  69. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  70. package/src/lib/runtime/ai-node-tree.d.ts +2 -0
  71. package/src/lib/runtime/ai-node-tree.js +16 -0
  72. package/src/lib/runtime/ai-node-tree.js.map +1 -1
  73. package/src/lib/tools/ai-dataset-tool.d.ts +68 -0
  74. package/src/lib/tools/ai-dataset-tool.js +231 -0
  75. package/src/lib/tools/ai-dataset-tool.js.map +1 -0
  76. package/src/lib/usage/free-ai-credits-left.js +26 -2
  77. package/src/lib/usage/free-ai-credits-left.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-ai",
3
- "version": "1.0.0-beta.238",
3
+ "version": "1.0.0-beta.239",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,22 +25,22 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.238",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.238",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.238",
31
- "@aglyn/shared-data-types": "1.0.0-beta.238",
32
- "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.238",
33
- "@aglyn/shared-ui-jsx": "1.0.0-beta.238",
34
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.238",
35
- "@aglyn/shared-ui-theme": "1.0.0-beta.238",
36
- "@aglyn/shared-util-email": "1.0.0-beta.238",
37
- "@aglyn/shared-util-errors": "1.0.0-beta.238",
38
- "@aglyn/shared-util-http": "1.0.0-beta.238",
39
- "@aglyn/shared-util-timestamp": "1.0.0-beta.238",
40
- "@aglyn/shared-util-tools": "1.0.0-beta.238",
41
- "@aglyn/tenant-data-admin": "1.0.0-beta.238",
42
- "@aglyn/tenant-feature-instance": "1.0.0-beta.238",
43
- "@aglyn/tenant-runtime": "1.0.0-beta.238",
28
+ "@aglyn/aglyn": "1.0.0-beta.239",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.239",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.239",
31
+ "@aglyn/shared-data-types": "1.0.0-beta.239",
32
+ "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.239",
33
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.239",
34
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.239",
35
+ "@aglyn/shared-ui-theme": "1.0.0-beta.239",
36
+ "@aglyn/shared-util-email": "1.0.0-beta.239",
37
+ "@aglyn/shared-util-errors": "1.0.0-beta.239",
38
+ "@aglyn/shared-util-http": "1.0.0-beta.239",
39
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.239",
40
+ "@aglyn/shared-util-tools": "1.0.0-beta.239",
41
+ "@aglyn/tenant-data-admin": "1.0.0-beta.239",
42
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.239",
43
+ "@aglyn/tenant-runtime": "1.0.0-beta.239",
44
44
  "@swc/helpers": "0.5.23"
45
45
  },
46
46
  "peerDependencies": {
@@ -72,6 +72,14 @@ import { AI_JOB_BESIGNER_SEGMENT as BESIGNER_SEGMENT, AI_SITE_BUILD_HREF } from
72
72
  host
73
73
  });
74
74
  }
75
+ if (output.resource === 'draft' && output.draftResource) {
76
+ // A draft another plugin wrote — a dataset a site start or a build made
77
+ // (AGL-3616) — opens where its owner lists it, or nowhere it is not loaded.
78
+ return pluginRecordListHref(output.draftResource, {
79
+ orgSlug,
80
+ host
81
+ });
82
+ }
75
83
  if (output.resource === 'form') {
76
84
  // A new form has no version for the besigner to open: its own page — the
77
85
  // forms plugin's address for it — mints the first one, and holds the
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/components/ai-job-links.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport { buildRoute, Route } from '@aglyn/aglyn/app-utils/console-routes'\nimport {\n pluginRecordHref,\n pluginRecordListHref,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { AI_JOB_BESIGNER_SEGMENT as BESIGNER_SEGMENT, AI_SITE_BUILD_HREF } from '../model/ai-job-notice'\nimport type { AiJobOutput, AiJobSummary } from '../model/ai-jobs.types'\n\n// Where a job's work opens in the console (AGL-2904), shared by the AI jobs\n// drawer and every dialog that follows the job it started (AGL-3593).\n\n/**\n * Where \"open draft\" goes, or `null` when the output has no page of its\n * own — a `text` output carries its copy on the job and is shown inline.\n * A versioned resource opens in the besigner on the version the job wrote;\n * one the console lists without a detail page opens its list; a `theme`\n * proposal opens the site's Theme section, where it is put in the editor.\n *\n * A console URL names a site by its SUBDOMAIN: the `[host]` segment resolves\n * through the member's host projection by subdomain, never by document id.\n * So an output that carries no `hostSubdomain` gets no link rather than one\n * that opens no site.\n */\nexport function aiJobOutputHref(output: AiJobOutput, orgSlug: string): string | null {\n const host = output.hostSubdomain\n if (!orgSlug || !host) return null\n const segment = BESIGNER_SEGMENT[output.resource]\n if (segment) {\n const base = `/${orgSlug}/hosts/${host}/${segment}/${output.id}`\n return output.versionId ? `${base}/versions/${output.versionId}/besigner` : base\n }\n if (output.resource === 'product') {\n // The catalog a drafted product lands in: the commerce plugin's address\n // for it (AGL-3080), or no link where commerce is not loaded.\n return pluginRecordListHref('product', { orgSlug, host })\n }\n if (output.resource === 'workflow') {\n // A drafted automation is an action (AGL-2919), listed switched off on\n // the workflows plugin's list of actions, or no link where it is not loaded.\n return pluginRecordListHref('action', { orgSlug, host })\n }\n if (output.resource === 'entry') {\n // A post a guided start wrote (AGL-3676) opens in its blog's list in Content.\n const collectionSlug = output.proposal?.['collectionSlug']\n return typeof collectionSlug === 'string' && collectionSlug\n ? buildRoute(Route.HOST_CONTENT_COLLECTION, { orgSlug, host, collectionSlug })\n : buildRoute(Route.HOST_CONTENT, { orgSlug, host })\n }\n if (output.resource === 'theme') {\n return buildRoute(Route.HOST_SETUP_THEME, { orgSlug, host })\n }\n if (output.resource === 'form') {\n // A new form has no version for the besigner to open: its own page — the\n // forms plugin's address for it — mints the first one, and holds the\n // routing and consent it declares.\n return pluginRecordHref('form', { orgSlug, host }, output.id)\n }\n if (output.resource === 'campaign') {\n // A campaign's page is the marketing plugin's, under the site: its\n // address for the campaign (AGL-3080), or no link where it is not loaded.\n return pluginRecordHref('campaign', { orgSlug, host }, output.id)\n }\n return null\n}\n\n/**\n * The ONE action a finished job's row, its notification and the dialog that\n * started it lead with (AGL-3593): what the job built. Several pages from one\n * job — a site's — open the site's Pages list, where they wait as drafts; one\n * output with a page of its own opens that page; anything else has no single\n * place to go, and the row's own links stand. `null` until the job is done.\n */\nexport function aiJobPrimaryLink(\n job: Pick<AiJobSummary, 'status' | 'outputs'>,\n orgSlug: string,\n): { href: string; label: string } | null {\n if (job.status !== 'done' || !orgSlug) return null\n const screens = job.outputs.filter(\n (output) => output.resource === 'screen' && output.hostSubdomain,\n )\n if (screens.length > 1) {\n return {\n href: buildRoute(Route.HOST_SCREENS, { orgSlug, host: String(screens[0].hostSubdomain) }),\n label: 'Open your draft pages',\n }\n }\n for (const output of job.outputs) {\n const href = aiJobOutputHref(output, orgSlug)\n if (href) {\n return {\n href,\n label: output.resource === 'screen' ? 'Open the draft page' : `Open ${output.label}`,\n }\n }\n }\n return null\n}\n\nexport { AI_SITE_BUILD_HREF }\n\n/**\n * The \"Building your site\" page for one site job (AGL-3594): linkable, so the\n * guided start lands on it, a reload keeps it and a notification opens it.\n * `null` without the org slug or the site's subdomain a console URL needs.\n */\nexport function aiSiteBuildHref(\n orgSlug: string | null | undefined,\n hostSubdomain: string | null | undefined,\n jobId: string,\n): string | null {\n if (!orgSlug || !hostSubdomain || !jobId) return null\n return `/${orgSlug}/hosts/${hostSubdomain}${AI_SITE_BUILD_HREF}/${encodeURIComponent(jobId)}`\n}\n\n/**\n * A finished site's two ways in (AGL-3594): its first page's draft, opened in\n * the console's preview of that version, and the Pages list every draft waits\n * in. Either is `null` where the job reported nothing to open.\n */\nexport function aiSiteBuildDoneLinks(\n job: Pick<AiJobSummary, 'outputs'>,\n orgSlug: string,\n): { view: string | null; pages: string | null } {\n const screens = job.outputs.filter((output) => output.resource === 'screen' && output.hostSubdomain)\n const first = screens[0]\n if (!first || !orgSlug) return { view: null, pages: null }\n const host = String(first.hostSubdomain)\n return {\n view: first.versionId\n ? `/${orgSlug}/hosts/${host}/screens/${first.id}/versions/${first.versionId}/view`\n : null,\n pages: buildRoute(Route.HOST_SCREENS, { orgSlug, host }),\n }\n}\n\n/**\n * Where a person gets more AI credits (AGL-3660): the workspace's Billing\n * page at its plans, where an upgrade and the AI add-on are both sold — the\n * door the AI credits meter's own upgrade link opens.\n */\nexport function aiCreditsBillingHref(orgSlug: string): string {\n return `${buildRoute(Route.MANAGE_BILLING, { orgSlug })}#plans`\n}\n"],"names":["buildRoute","Route","pluginRecordHref","pluginRecordListHref","AI_JOB_BESIGNER_SEGMENT","BESIGNER_SEGMENT","AI_SITE_BUILD_HREF","aiJobOutputHref","output","orgSlug","host","hostSubdomain","segment","resource","base","id","versionId","collectionSlug","proposal","HOST_CONTENT_COLLECTION","HOST_CONTENT","HOST_SETUP_THEME","aiJobPrimaryLink","job","status","screens","outputs","filter","length","href","HOST_SCREENS","String","label","aiSiteBuildHref","jobId","encodeURIComponent","aiSiteBuildDoneLinks","first","view","pages","aiCreditsBillingHref","MANAGE_BILLING"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/components/ai-job-links.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport { buildRoute, Route } from '@aglyn/aglyn/app-utils/console-routes'\nimport {\n pluginRecordHref,\n pluginRecordListHref,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { AI_JOB_BESIGNER_SEGMENT as BESIGNER_SEGMENT, AI_SITE_BUILD_HREF } from '../model/ai-job-notice'\nimport type { AiJobOutput, AiJobSummary } from '../model/ai-jobs.types'\n\n// Where a job's work opens in the console (AGL-2904), shared by the AI jobs\n// drawer and every dialog that follows the job it started (AGL-3593).\n\n/**\n * Where \"open draft\" goes, or `null` when the output has no page of its\n * own — a `text` output carries its copy on the job and is shown inline.\n * A versioned resource opens in the besigner on the version the job wrote;\n * one the console lists without a detail page opens its list; a `theme`\n * proposal opens the site's Theme section, where it is put in the editor.\n *\n * A console URL names a site by its SUBDOMAIN: the `[host]` segment resolves\n * through the member's host projection by subdomain, never by document id.\n * So an output that carries no `hostSubdomain` gets no link rather than one\n * that opens no site.\n */\nexport function aiJobOutputHref(output: AiJobOutput, orgSlug: string): string | null {\n const host = output.hostSubdomain\n if (!orgSlug || !host) return null\n const segment = BESIGNER_SEGMENT[output.resource]\n if (segment) {\n const base = `/${orgSlug}/hosts/${host}/${segment}/${output.id}`\n return output.versionId ? `${base}/versions/${output.versionId}/besigner` : base\n }\n if (output.resource === 'product') {\n // The catalog a drafted product lands in: the commerce plugin's address\n // for it (AGL-3080), or no link where commerce is not loaded.\n return pluginRecordListHref('product', { orgSlug, host })\n }\n if (output.resource === 'workflow') {\n // A drafted automation is an action (AGL-2919), listed switched off on\n // the workflows plugin's list of actions, or no link where it is not loaded.\n return pluginRecordListHref('action', { orgSlug, host })\n }\n if (output.resource === 'entry') {\n // A post a guided start wrote (AGL-3676) opens in its blog's list in Content.\n const collectionSlug = output.proposal?.['collectionSlug']\n return typeof collectionSlug === 'string' && collectionSlug\n ? buildRoute(Route.HOST_CONTENT_COLLECTION, { orgSlug, host, collectionSlug })\n : buildRoute(Route.HOST_CONTENT, { orgSlug, host })\n }\n if (output.resource === 'theme') {\n return buildRoute(Route.HOST_SETUP_THEME, { orgSlug, host })\n }\n if (output.resource === 'draft' && output.draftResource) {\n // A draft another plugin wrote — a dataset a site start or a build made\n // (AGL-3616) — opens where its owner lists it, or nowhere it is not loaded.\n return pluginRecordListHref(output.draftResource, { orgSlug, host })\n }\n if (output.resource === 'form') {\n // A new form has no version for the besigner to open: its own page — the\n // forms plugin's address for it — mints the first one, and holds the\n // routing and consent it declares.\n return pluginRecordHref('form', { orgSlug, host }, output.id)\n }\n if (output.resource === 'campaign') {\n // A campaign's page is the marketing plugin's, under the site: its\n // address for the campaign (AGL-3080), or no link where it is not loaded.\n return pluginRecordHref('campaign', { orgSlug, host }, output.id)\n }\n return null\n}\n\n/**\n * The ONE action a finished job's row, its notification and the dialog that\n * started it lead with (AGL-3593): what the job built. Several pages from one\n * job — a site's — open the site's Pages list, where they wait as drafts; one\n * output with a page of its own opens that page; anything else has no single\n * place to go, and the row's own links stand. `null` until the job is done.\n */\nexport function aiJobPrimaryLink(\n job: Pick<AiJobSummary, 'status' | 'outputs'>,\n orgSlug: string,\n): { href: string; label: string } | null {\n if (job.status !== 'done' || !orgSlug) return null\n const screens = job.outputs.filter(\n (output) => output.resource === 'screen' && output.hostSubdomain,\n )\n if (screens.length > 1) {\n return {\n href: buildRoute(Route.HOST_SCREENS, { orgSlug, host: String(screens[0].hostSubdomain) }),\n label: 'Open your draft pages',\n }\n }\n for (const output of job.outputs) {\n const href = aiJobOutputHref(output, orgSlug)\n if (href) {\n return {\n href,\n label: output.resource === 'screen' ? 'Open the draft page' : `Open ${output.label}`,\n }\n }\n }\n return null\n}\n\nexport { AI_SITE_BUILD_HREF }\n\n/**\n * The \"Building your site\" page for one site job (AGL-3594): linkable, so the\n * guided start lands on it, a reload keeps it and a notification opens it.\n * `null` without the org slug or the site's subdomain a console URL needs.\n */\nexport function aiSiteBuildHref(\n orgSlug: string | null | undefined,\n hostSubdomain: string | null | undefined,\n jobId: string,\n): string | null {\n if (!orgSlug || !hostSubdomain || !jobId) return null\n return `/${orgSlug}/hosts/${hostSubdomain}${AI_SITE_BUILD_HREF}/${encodeURIComponent(jobId)}`\n}\n\n/**\n * A finished site's two ways in (AGL-3594): its first page's draft, opened in\n * the console's preview of that version, and the Pages list every draft waits\n * in. Either is `null` where the job reported nothing to open.\n */\nexport function aiSiteBuildDoneLinks(\n job: Pick<AiJobSummary, 'outputs'>,\n orgSlug: string,\n): { view: string | null; pages: string | null } {\n const screens = job.outputs.filter((output) => output.resource === 'screen' && output.hostSubdomain)\n const first = screens[0]\n if (!first || !orgSlug) return { view: null, pages: null }\n const host = String(first.hostSubdomain)\n return {\n view: first.versionId\n ? `/${orgSlug}/hosts/${host}/screens/${first.id}/versions/${first.versionId}/view`\n : null,\n pages: buildRoute(Route.HOST_SCREENS, { orgSlug, host }),\n }\n}\n\n/**\n * Where a person gets more AI credits (AGL-3660): the workspace's Billing\n * page at its plans, where an upgrade and the AI add-on are both sold — the\n * door the AI credits meter's own upgrade link opens.\n */\nexport function aiCreditsBillingHref(orgSlug: string): string {\n return `${buildRoute(Route.MANAGE_BILLING, { orgSlug })}#plans`\n}\n"],"names":["buildRoute","Route","pluginRecordHref","pluginRecordListHref","AI_JOB_BESIGNER_SEGMENT","BESIGNER_SEGMENT","AI_SITE_BUILD_HREF","aiJobOutputHref","output","orgSlug","host","hostSubdomain","segment","resource","base","id","versionId","collectionSlug","proposal","HOST_CONTENT_COLLECTION","HOST_CONTENT","HOST_SETUP_THEME","draftResource","aiJobPrimaryLink","job","status","screens","outputs","filter","length","href","HOST_SCREENS","String","label","aiSiteBuildHref","jobId","encodeURIComponent","aiSiteBuildDoneLinks","first","view","pages","aiCreditsBillingHref","MANAGE_BILLING"],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import { type TokenSource } from '@aglyn/shared-util-http/authorized-token';
2
+ import type { AiFreeCreditsLeft } from '../model/ai-site-job';
2
3
  import { type AiJobsActivity } from '../model/ai-job-activity';
3
4
  import { type AiJobSummary } from '../model/ai-jobs.types';
4
5
  /**
@@ -58,6 +59,17 @@ export declare function useAiJobsOpenRequest(): AiJobsOpenRequest;
58
59
  export { AI_JOB_LINK_PARAM } from '../model/ai-job-notice';
59
60
  /** How often a list with something in it is read again, while the tab is in view. */
60
61
  export declare const AI_JOBS_ACTIVE_POLL_MS = 15000;
62
+ /**
63
+ * Who hears of a read as it starts and what a Free workspace has left when it
64
+ * lands (AGL-3722): the usage strip's store follows the jobs list this way
65
+ * rather than by import, so this module stays out of the strip's chunk.
66
+ */
67
+ export interface AiJobsReadWatcher {
68
+ start: (uid: string, orgId: string) => void;
69
+ done: (uid: string, orgId: string, credits: AiFreeCreditsLeft | undefined) => void;
70
+ }
71
+ /** Listen to the jobs list's reads; returns the way to stop. */
72
+ export declare function watchAiJobsReads(watcher: AiJobsReadWatcher): () => void;
61
73
  /**
62
74
  * Hands a job's latest state to every surface counting this workspace's jobs:
63
75
  * a new job joins the list, a settled one leaves it. Keyed on the job's own
@@ -55,6 +55,13 @@ const readOpen = ()=>openRequest;
55
55
  /** The most unsettled jobs one read lists; a glance, not an archive. */ const ACTIVE_LIMIT = 20;
56
56
  const NONE = Object.freeze([]);
57
57
  const entries = new Map();
58
+ const readWatchers = new Set();
59
+ /** Listen to the jobs list's reads; returns the way to stop. */ export function watchAiJobsReads(watcher) {
60
+ readWatchers.add(watcher);
61
+ return ()=>{
62
+ readWatchers.delete(watcher);
63
+ };
64
+ }
58
65
  const keyOf = (uid, orgId)=>`${uid}\n${orgId}`;
59
66
  function emit(entry) {
60
67
  for (const listener of entry.listeners)listener();
@@ -77,6 +84,7 @@ function schedule(entry) {
77
84
  async function read(entry) {
78
85
  if (entry.inflight || entry.closed) return;
79
86
  entry.inflight = true;
87
+ for (const watcher of readWatchers)watcher.start(entry.uid, entry.orgId);
80
88
  try {
81
89
  var _ref;
82
90
  const response = await authorizedFetch(entry.user, `/api/ai/jobs?orgId=${encodeURIComponent(entry.orgId)}&status=active&limit=${ACTIVE_LIMIT}`);
@@ -90,6 +98,9 @@ async function read(entry) {
90
98
  }
91
99
  if (!response.ok) return;
92
100
  const payload = await response.json().catch(()=>null);
101
+ // The same read carries what a Free workspace has left, so the usage
102
+ // strip follows jobs that spend between chat messages (AGL-3722).
103
+ for (const watcher of readWatchers)watcher.done(entry.uid, entry.orgId, payload == null ? void 0 : payload.freeCredits);
93
104
  entry.jobs = Object.freeze(((_ref = payload == null ? void 0 : payload.jobs) != null ? _ref : []).filter((job)=>!isSettled(job)));
94
105
  emit(entry);
95
106
  } catch (unused) {
@@ -107,6 +118,7 @@ function entryFor(uid, orgId, user) {
107
118
  jobs: NONE,
108
119
  user,
109
120
  orgId,
121
+ uid,
110
122
  subscribers: 0,
111
123
  inflight: false,
112
124
  timer: null,
@@ -130,6 +142,8 @@ function entryFor(uid, orgId, user) {
130
142
  if (isSettled(job)) {
131
143
  if (index === -1) continue;
132
144
  next = entry.jobs.filter((known)=>known.id !== job.id);
145
+ // A job that just settled has just spent: read what is left (AGL-3722).
146
+ if (entry.subscribers > 0) void read(entry);
133
147
  } else if (index === -1) {
134
148
  next = [
135
149
  job,
@@ -153,6 +167,7 @@ function entryFor(uid, orgId, user) {
153
167
  }
154
168
  entries.clear();
155
169
  openListeners.clear();
170
+ readWatchers.clear();
156
171
  openRequest = {
157
172
  seq: 0,
158
173
  jobId: null
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/components/ai-jobs-store.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n authorizedFetch,\n type MaybeTokenSource,\n type TokenSource,\n} from '@aglyn/shared-util-http/authorized-token'\nimport { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'\nimport { aiJobsActivity, type AiJobsActivity } from '../model/ai-job-activity'\nimport { AI_JOB_TERMINAL_STATUSES, type AiJobSummary } from '../model/ai-jobs.types'\n\n/**\n * WHERE A PERSON FINDS THEIR AI JOBS (AGL-3593).\n *\n * Two things every AI surface in the console shares, kept in this module\n * rather than in any one component because the surfaces that need them — a\n * brief dialog on the Screens page, the guided start's full-screen dialog,\n * the top-bar indicator, the Assist launcher and the drawer inside the\n * panel — are mounted in different zones and know nothing of one another.\n * All of them are this plugin's, so no shell, zone or event bus carries it.\n *\n * ── Opening AI jobs ──────────────────────────────────────────────────────\n *\n * {@link openAiJobs} is the one action every \"Open AI jobs\" button, the\n * indicator and a notification's link take: the Assist panel subscribes, opens\n * itself, expands its AI jobs drawer, and scrolls to and highlights the job\n * named. A request is a sequence number as well as a job, so asking twice for\n * the same job opens it twice.\n *\n * ── The workspace's unsettled jobs ───────────────────────────────────────\n *\n * The indicator, the launcher's badge and the drawer's open-by-default rule\n * read the same list: the workspace's jobs that are not yet settled, from\n * the jobs route's `status=active` (a Firestore `in` query under the\n * (status, createdAt) index, never a filter of a loaded window). ONE list per\n * person and workspace however many surfaces read it, re-read on a timer only\n * while something in it is still moving or waiting and the tab is in view —\n * a workspace with nothing running costs one read per page load and no more.\n *\n * Every surface that learns a job's state some other way — the dialog that\n * created it, the drawer's event stream, the in-dialog confirm — hands it in\n * through {@link publishAiJob}, so the indicator moves the moment the job\n * does rather than on the next read.\n */\n\n// ── Opening AI jobs ───────────────────────────────────────────────────────\n\n/** The latest request to open AI jobs; `seq` is 0 until anything asked. */\nexport interface AiJobsOpenRequest {\n seq: number\n /** The job to scroll to and highlight, or `null` for the drawer alone. */\n jobId: string | null\n}\n\nlet openRequest: AiJobsOpenRequest = { seq: 0, jobId: null }\nconst openListeners = new Set<() => void>()\n\n/**\n * Opens the Assist panel on its AI jobs drawer, expanded, scrolled to\n * `jobId` when one is named. A surface that covers the panel — a dialog —\n * closes itself beside the call; this opens, it does not close anything.\n */\nexport function openAiJobs(options: { jobId?: string | null } = {}): void {\n openRequest = { seq: openRequest.seq + 1, jobId: options.jobId ?? null }\n for (const listener of openListeners) listener()\n}\n\nfunction subscribeOpen(listener: () => void): () => void {\n openListeners.add(listener)\n return () => {\n openListeners.delete(listener)\n }\n}\n\nconst readOpen = (): AiJobsOpenRequest => openRequest\n\n/** The latest open request, for the panel; a new `seq` is a new request. */\nexport function useAiJobsOpenRequest(): AiJobsOpenRequest {\n return useSyncExternalStore(subscribeOpen, readOpen, readOpen)\n}\n\n/**\n * The query parameter a notification's link carries: a console page opened\n * with `?aiJob={id}` opens AI jobs on that job, the way pressing the\n * indicator does.\n */\nexport { AI_JOB_LINK_PARAM } from '../model/ai-job-notice'\n\n// ── The workspace's unsettled jobs ────────────────────────────────────────\n\n/** How often a list with something in it is read again, while the tab is in view. */\nexport const AI_JOBS_ACTIVE_POLL_MS = 15_000\n\n/** The most unsettled jobs one read lists; a glance, not an archive. */\nconst ACTIVE_LIMIT = 20\n\nconst NONE: readonly AiJobSummary[] = Object.freeze([])\n\ninterface Entry {\n jobs: readonly AiJobSummary[]\n /** Who reads it, kept current by whichever surface subscribed last. */\n user: MaybeTokenSource\n orgId: string\n subscribers: number\n inflight: boolean\n timer: ReturnType<typeof setTimeout> | null\n /** The route said the feature is not this workspace's: nothing is read again. */\n closed: boolean\n listeners: Set<() => void>\n}\n\nconst entries = new Map<string, Entry>()\n\nconst keyOf = (uid: string, orgId: string): string => `${uid}\\n${orgId}`\n\nfunction emit(entry: Entry): void {\n for (const listener of entry.listeners) listener()\n}\n\nfunction isSettled(job: AiJobSummary): boolean {\n return AI_JOB_TERMINAL_STATUSES.includes(job.status)\n}\n\nfunction schedule(entry: Entry): void {\n if (entry.timer) clearTimeout(entry.timer)\n entry.timer = null\n if (entry.closed || entry.subscribers === 0 || entry.jobs.length === 0) return\n entry.timer = setTimeout(() => {\n entry.timer = null\n const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'\n // A hidden tab reads nothing; coming back into view reads at once.\n if (hidden) return\n void read(entry)\n }, AI_JOBS_ACTIVE_POLL_MS)\n}\n\nasync function read(entry: Entry): Promise<void> {\n if (entry.inflight || entry.closed) return\n entry.inflight = true\n try {\n const response = await authorizedFetch(\n entry.user,\n `/api/ai/jobs?orgId=${encodeURIComponent(entry.orgId)}&status=active&limit=${ACTIVE_LIMIT}`,\n )\n if (response.status === 404 || response.status === 403) {\n // The release flag off, or a plan or member without generation: there\n // is nothing to count, now or on the next tick.\n entry.closed = true\n entry.jobs = NONE\n emit(entry)\n return\n }\n if (!response.ok) return\n const payload = (await response.json().catch(() => null)) as { jobs?: AiJobSummary[] } | null\n entry.jobs = Object.freeze((payload?.jobs ?? []).filter((job) => !isSettled(job)))\n emit(entry)\n } catch {\n // An unreachable route keeps what was last read; the next tick asks again.\n } finally {\n entry.inflight = false\n schedule(entry)\n }\n}\n\nfunction entryFor(uid: string, orgId: string, user: MaybeTokenSource): Entry {\n const key = keyOf(uid, orgId)\n let entry = entries.get(key)\n if (!entry) {\n entry = {\n jobs: NONE,\n user,\n orgId,\n subscribers: 0,\n inflight: false,\n timer: null,\n closed: false,\n listeners: new Set(),\n }\n entries.set(key, entry)\n }\n return entry\n}\n\n/**\n * Hands a job's latest state to every surface counting this workspace's jobs:\n * a new job joins the list, a settled one leaves it. Keyed on the job's own\n * workspace, for every person whose list holds that workspace in this tab.\n */\nexport function publishAiJob(job: AiJobSummary | null | undefined): void {\n if (!job?.orgId) return\n for (const entry of entries.values()) {\n if (entry.orgId !== job.orgId || entry.closed) continue\n const index = entry.jobs.findIndex((known) => known.id === job.id)\n let next: AiJobSummary[]\n if (isSettled(job)) {\n if (index === -1) continue\n next = entry.jobs.filter((known) => known.id !== job.id)\n } else if (index === -1) {\n next = [job, ...entry.jobs]\n } else {\n next = [...entry.jobs]\n next[index] = job\n }\n entry.jobs = Object.freeze(next)\n emit(entry)\n // A list that just gained its first job starts its timer.\n if (!entry.timer && !entry.inflight) schedule(entry)\n }\n}\n\n/** Forget every list and request; specs start each case from nothing. */\nexport function resetAiJobsStoreForTests(): void {\n for (const entry of entries.values()) {\n if (entry.timer) clearTimeout(entry.timer)\n }\n entries.clear()\n openListeners.clear()\n openRequest = { seq: 0, jobId: null }\n}\n\n/**\n * The workspace's jobs that are not yet settled, newest first, for the\n * signed-in person — empty while the first read is in flight, where the\n * route refuses, and when `enabled` is false (a surface whose own gates have\n * not passed reads nothing at all).\n */\nexport function useAiJobsInFlight(\n user: (TokenSource & { uid?: string | null }) | null | undefined,\n orgId: string | null | undefined,\n enabled = true,\n): readonly AiJobSummary[] {\n const uid = user?.uid ?? null\n const userRef = useRef<MaybeTokenSource>(user)\n userRef.current = user\n const entry = useMemo(\n () => (enabled && uid && orgId ? entryFor(uid, orgId, userRef.current) : null),\n [enabled, uid, orgId],\n )\n\n useEffect(() => {\n if (!entry) return undefined\n entry.user = userRef.current\n entry.subscribers += 1\n if (entry.subscribers === 1) void read(entry)\n const onVisible = () => {\n if (document.visibilityState === 'visible' && entry.jobs.length > 0) void read(entry)\n }\n document.addEventListener('visibilitychange', onVisible)\n return () => {\n document.removeEventListener('visibilitychange', onVisible)\n entry.subscribers -= 1\n if (entry.subscribers === 0 && entry.timer) {\n clearTimeout(entry.timer)\n entry.timer = null\n }\n }\n }, [entry])\n\n const subscribe = useMemo(\n () => (listener: () => void) => {\n if (!entry) return () => undefined\n entry.listeners.add(listener)\n return () => {\n entry.listeners.delete(listener)\n }\n },\n [entry],\n )\n const snapshot = (): readonly AiJobSummary[] => entry?.jobs ?? NONE\n return useSyncExternalStore(subscribe, snapshot, snapshot)\n}\n\n/** What the indicator and the launcher show for the workspace, or `null` for nothing. */\nexport function useAiJobsActivity(\n user: (TokenSource & { uid?: string | null }) | null | undefined,\n orgId: string | null | undefined,\n enabled = true,\n): AiJobsActivity<AiJobSummary> | null {\n const jobs = useAiJobsInFlight(user, orgId, enabled)\n return useMemo(() => aiJobsActivity(jobs), [jobs])\n}\n"],"names":["authorizedFetch","useEffect","useMemo","useRef","useSyncExternalStore","aiJobsActivity","AI_JOB_TERMINAL_STATUSES","openRequest","seq","jobId","openListeners","Set","openAiJobs","options","listener","subscribeOpen","add","delete","readOpen","useAiJobsOpenRequest","AI_JOB_LINK_PARAM","AI_JOBS_ACTIVE_POLL_MS","ACTIVE_LIMIT","NONE","Object","freeze","entries","Map","keyOf","uid","orgId","emit","entry","listeners","isSettled","job","includes","status","schedule","timer","clearTimeout","closed","subscribers","jobs","length","setTimeout","hidden","document","visibilityState","read","inflight","response","user","encodeURIComponent","ok","payload","json","catch","filter","entryFor","key","get","set","publishAiJob","values","index","findIndex","known","id","next","resetAiJobsStoreForTests","clear","useAiJobsInFlight","enabled","userRef","current","undefined","onVisible","addEventListener","removeEventListener","subscribe","snapshot","useAiJobsActivity"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/components/ai-jobs-store.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n authorizedFetch,\n type MaybeTokenSource,\n type TokenSource,\n} from '@aglyn/shared-util-http/authorized-token'\nimport { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'\nimport type { AiFreeCreditsLeft } from '../model/ai-site-job'\nimport { aiJobsActivity, type AiJobsActivity } from '../model/ai-job-activity'\nimport { AI_JOB_TERMINAL_STATUSES, type AiJobSummary } from '../model/ai-jobs.types'\n\n/**\n * WHERE A PERSON FINDS THEIR AI JOBS (AGL-3593).\n *\n * Two things every AI surface in the console shares, kept in this module\n * rather than in any one component because the surfaces that need them — a\n * brief dialog on the Screens page, the guided start's full-screen dialog,\n * the top-bar indicator, the Assist launcher and the drawer inside the\n * panel — are mounted in different zones and know nothing of one another.\n * All of them are this plugin's, so no shell, zone or event bus carries it.\n *\n * ── Opening AI jobs ──────────────────────────────────────────────────────\n *\n * {@link openAiJobs} is the one action every \"Open AI jobs\" button, the\n * indicator and a notification's link take: the Assist panel subscribes, opens\n * itself, expands its AI jobs drawer, and scrolls to and highlights the job\n * named. A request is a sequence number as well as a job, so asking twice for\n * the same job opens it twice.\n *\n * ── The workspace's unsettled jobs ───────────────────────────────────────\n *\n * The indicator, the launcher's badge and the drawer's open-by-default rule\n * read the same list: the workspace's jobs that are not yet settled, from\n * the jobs route's `status=active` (a Firestore `in` query under the\n * (status, createdAt) index, never a filter of a loaded window). ONE list per\n * person and workspace however many surfaces read it, re-read on a timer only\n * while something in it is still moving or waiting and the tab is in view —\n * a workspace with nothing running costs one read per page load and no more.\n *\n * Every surface that learns a job's state some other way — the dialog that\n * created it, the drawer's event stream, the in-dialog confirm — hands it in\n * through {@link publishAiJob}, so the indicator moves the moment the job\n * does rather than on the next read.\n */\n\n// ── Opening AI jobs ───────────────────────────────────────────────────────\n\n/** The latest request to open AI jobs; `seq` is 0 until anything asked. */\nexport interface AiJobsOpenRequest {\n seq: number\n /** The job to scroll to and highlight, or `null` for the drawer alone. */\n jobId: string | null\n}\n\nlet openRequest: AiJobsOpenRequest = { seq: 0, jobId: null }\nconst openListeners = new Set<() => void>()\n\n/**\n * Opens the Assist panel on its AI jobs drawer, expanded, scrolled to\n * `jobId` when one is named. A surface that covers the panel — a dialog —\n * closes itself beside the call; this opens, it does not close anything.\n */\nexport function openAiJobs(options: { jobId?: string | null } = {}): void {\n openRequest = { seq: openRequest.seq + 1, jobId: options.jobId ?? null }\n for (const listener of openListeners) listener()\n}\n\nfunction subscribeOpen(listener: () => void): () => void {\n openListeners.add(listener)\n return () => {\n openListeners.delete(listener)\n }\n}\n\nconst readOpen = (): AiJobsOpenRequest => openRequest\n\n/** The latest open request, for the panel; a new `seq` is a new request. */\nexport function useAiJobsOpenRequest(): AiJobsOpenRequest {\n return useSyncExternalStore(subscribeOpen, readOpen, readOpen)\n}\n\n/**\n * The query parameter a notification's link carries: a console page opened\n * with `?aiJob={id}` opens AI jobs on that job, the way pressing the\n * indicator does.\n */\nexport { AI_JOB_LINK_PARAM } from '../model/ai-job-notice'\n\n// ── The workspace's unsettled jobs ────────────────────────────────────────\n\n/** How often a list with something in it is read again, while the tab is in view. */\nexport const AI_JOBS_ACTIVE_POLL_MS = 15_000\n\n/** The most unsettled jobs one read lists; a glance, not an archive. */\nconst ACTIVE_LIMIT = 20\n\nconst NONE: readonly AiJobSummary[] = Object.freeze([])\n\ninterface Entry {\n jobs: readonly AiJobSummary[]\n /** Who reads it, kept current by whichever surface subscribed last. */\n user: MaybeTokenSource\n orgId: string\n uid: string\n subscribers: number\n inflight: boolean\n timer: ReturnType<typeof setTimeout> | null\n /** The route said the feature is not this workspace's: nothing is read again. */\n closed: boolean\n listeners: Set<() => void>\n}\n\nconst entries = new Map<string, Entry>()\n\n/**\n * Who hears of a read as it starts and what a Free workspace has left when it\n * lands (AGL-3722): the usage strip's store follows the jobs list this way\n * rather than by import, so this module stays out of the strip's chunk.\n */\nexport interface AiJobsReadWatcher {\n start: (uid: string, orgId: string) => void\n done: (uid: string, orgId: string, credits: AiFreeCreditsLeft | undefined) => void\n}\nconst readWatchers = new Set<AiJobsReadWatcher>()\n\n/** Listen to the jobs list's reads; returns the way to stop. */\nexport function watchAiJobsReads(watcher: AiJobsReadWatcher): () => void {\n readWatchers.add(watcher)\n return () => {\n readWatchers.delete(watcher)\n }\n}\n\nconst keyOf = (uid: string, orgId: string): string => `${uid}\\n${orgId}`\n\nfunction emit(entry: Entry): void {\n for (const listener of entry.listeners) listener()\n}\n\nfunction isSettled(job: AiJobSummary): boolean {\n return AI_JOB_TERMINAL_STATUSES.includes(job.status)\n}\n\nfunction schedule(entry: Entry): void {\n if (entry.timer) clearTimeout(entry.timer)\n entry.timer = null\n if (entry.closed || entry.subscribers === 0 || entry.jobs.length === 0) return\n entry.timer = setTimeout(() => {\n entry.timer = null\n const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'\n // A hidden tab reads nothing; coming back into view reads at once.\n if (hidden) return\n void read(entry)\n }, AI_JOBS_ACTIVE_POLL_MS)\n}\n\nasync function read(entry: Entry): Promise<void> {\n if (entry.inflight || entry.closed) return\n entry.inflight = true\n for (const watcher of readWatchers) watcher.start(entry.uid, entry.orgId)\n try {\n const response = await authorizedFetch(\n entry.user,\n `/api/ai/jobs?orgId=${encodeURIComponent(entry.orgId)}&status=active&limit=${ACTIVE_LIMIT}`,\n )\n if (response.status === 404 || response.status === 403) {\n // The release flag off, or a plan or member without generation: there\n // is nothing to count, now or on the next tick.\n entry.closed = true\n entry.jobs = NONE\n emit(entry)\n return\n }\n if (!response.ok) return\n const payload = (await response.json().catch(() => null)) as {\n jobs?: AiJobSummary[]\n freeCredits?: AiFreeCreditsLeft\n } | null\n // The same read carries what a Free workspace has left, so the usage\n // strip follows jobs that spend between chat messages (AGL-3722).\n for (const watcher of readWatchers) watcher.done(entry.uid, entry.orgId, payload?.freeCredits)\n entry.jobs = Object.freeze((payload?.jobs ?? []).filter((job) => !isSettled(job)))\n emit(entry)\n } catch {\n // An unreachable route keeps what was last read; the next tick asks again.\n } finally {\n entry.inflight = false\n schedule(entry)\n }\n}\n\nfunction entryFor(uid: string, orgId: string, user: MaybeTokenSource): Entry {\n const key = keyOf(uid, orgId)\n let entry = entries.get(key)\n if (!entry) {\n entry = {\n jobs: NONE,\n user,\n orgId,\n uid,\n subscribers: 0,\n inflight: false,\n timer: null,\n closed: false,\n listeners: new Set(),\n }\n entries.set(key, entry)\n }\n return entry\n}\n\n/**\n * Hands a job's latest state to every surface counting this workspace's jobs:\n * a new job joins the list, a settled one leaves it. Keyed on the job's own\n * workspace, for every person whose list holds that workspace in this tab.\n */\nexport function publishAiJob(job: AiJobSummary | null | undefined): void {\n if (!job?.orgId) return\n for (const entry of entries.values()) {\n if (entry.orgId !== job.orgId || entry.closed) continue\n const index = entry.jobs.findIndex((known) => known.id === job.id)\n let next: AiJobSummary[]\n if (isSettled(job)) {\n if (index === -1) continue\n next = entry.jobs.filter((known) => known.id !== job.id)\n // A job that just settled has just spent: read what is left (AGL-3722).\n if (entry.subscribers > 0) void read(entry)\n } else if (index === -1) {\n next = [job, ...entry.jobs]\n } else {\n next = [...entry.jobs]\n next[index] = job\n }\n entry.jobs = Object.freeze(next)\n emit(entry)\n // A list that just gained its first job starts its timer.\n if (!entry.timer && !entry.inflight) schedule(entry)\n }\n}\n\n/** Forget every list and request; specs start each case from nothing. */\nexport function resetAiJobsStoreForTests(): void {\n for (const entry of entries.values()) {\n if (entry.timer) clearTimeout(entry.timer)\n }\n entries.clear()\n openListeners.clear()\n readWatchers.clear()\n openRequest = { seq: 0, jobId: null }\n}\n\n/**\n * The workspace's jobs that are not yet settled, newest first, for the\n * signed-in person — empty while the first read is in flight, where the\n * route refuses, and when `enabled` is false (a surface whose own gates have\n * not passed reads nothing at all).\n */\nexport function useAiJobsInFlight(\n user: (TokenSource & { uid?: string | null }) | null | undefined,\n orgId: string | null | undefined,\n enabled = true,\n): readonly AiJobSummary[] {\n const uid = user?.uid ?? null\n const userRef = useRef<MaybeTokenSource>(user)\n userRef.current = user\n const entry = useMemo(\n () => (enabled && uid && orgId ? entryFor(uid, orgId, userRef.current) : null),\n [enabled, uid, orgId],\n )\n\n useEffect(() => {\n if (!entry) return undefined\n entry.user = userRef.current\n entry.subscribers += 1\n if (entry.subscribers === 1) void read(entry)\n const onVisible = () => {\n if (document.visibilityState === 'visible' && entry.jobs.length > 0) void read(entry)\n }\n document.addEventListener('visibilitychange', onVisible)\n return () => {\n document.removeEventListener('visibilitychange', onVisible)\n entry.subscribers -= 1\n if (entry.subscribers === 0 && entry.timer) {\n clearTimeout(entry.timer)\n entry.timer = null\n }\n }\n }, [entry])\n\n const subscribe = useMemo(\n () => (listener: () => void) => {\n if (!entry) return () => undefined\n entry.listeners.add(listener)\n return () => {\n entry.listeners.delete(listener)\n }\n },\n [entry],\n )\n const snapshot = (): readonly AiJobSummary[] => entry?.jobs ?? NONE\n return useSyncExternalStore(subscribe, snapshot, snapshot)\n}\n\n/** What the indicator and the launcher show for the workspace, or `null` for nothing. */\nexport function useAiJobsActivity(\n user: (TokenSource & { uid?: string | null }) | null | undefined,\n orgId: string | null | undefined,\n enabled = true,\n): AiJobsActivity<AiJobSummary> | null {\n const jobs = useAiJobsInFlight(user, orgId, enabled)\n return useMemo(() => aiJobsActivity(jobs), [jobs])\n}\n"],"names":["authorizedFetch","useEffect","useMemo","useRef","useSyncExternalStore","aiJobsActivity","AI_JOB_TERMINAL_STATUSES","openRequest","seq","jobId","openListeners","Set","openAiJobs","options","listener","subscribeOpen","add","delete","readOpen","useAiJobsOpenRequest","AI_JOB_LINK_PARAM","AI_JOBS_ACTIVE_POLL_MS","ACTIVE_LIMIT","NONE","Object","freeze","entries","Map","readWatchers","watchAiJobsReads","watcher","keyOf","uid","orgId","emit","entry","listeners","isSettled","job","includes","status","schedule","timer","clearTimeout","closed","subscribers","jobs","length","setTimeout","hidden","document","visibilityState","read","inflight","start","response","user","encodeURIComponent","ok","payload","json","catch","done","freeCredits","filter","entryFor","key","get","set","publishAiJob","values","index","findIndex","known","id","next","resetAiJobsStoreForTests","clear","useAiJobsInFlight","enabled","userRef","current","undefined","onVisible","addEventListener","removeEventListener","subscribe","snapshot","useAiJobsActivity"],"mappings":"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"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { AiFreeCreditsLeft } from '../model/ai-site-job';
18
+ import type { AiUsageMeterWire } from '../usage/ai-usage-wire';
19
+ /**
20
+ * Bring a Free reader's usage strip up to what the jobs list just read
21
+ * (AGL-3722): a job spends credits between chat messages, and nothing else
22
+ * moves the strip. The warn state comes with the figures, worked out by the
23
+ * route, so the strip's rule is not shipped to the browser twice. Kept out of `use-ai-usage-meter.ts` because that file is
24
+ * in the plugin's eager bundle and the jobs store is not.
25
+ *
26
+ * Only an envelope already on the Free plan is touched, and `before` — the
27
+ * envelope held when the read began — guards against overwriting one a door
28
+ * published while the read was in flight, which is newer.
29
+ */
30
+ export declare function refreshAiUsageMeterFree(uid: string, orgId: string, credits: Pick<AiFreeCreditsLeft, 'left' | 'used'> | null | undefined, before: AiUsageMeterWire | null): void;
31
+ /**
32
+ * Keep the usage strip current from the jobs list's reads, for as long as the
33
+ * caller (the Assist panel) is mounted. Returns the way to stop.
34
+ */
35
+ export declare function followAiJobsForUsageMeter(): () => void;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { watchAiJobsReads } from "./ai-jobs-store.js";
18
+ import { publishAiUsageMeter, readAiUsageMeter } from "./use-ai-usage-meter.js";
19
+ /**
20
+ * Bring a Free reader's usage strip up to what the jobs list just read
21
+ * (AGL-3722): a job spends credits between chat messages, and nothing else
22
+ * moves the strip. The warn state comes with the figures, worked out by the
23
+ * route, so the strip's rule is not shipped to the browser twice. Kept out of `use-ai-usage-meter.ts` because that file is
24
+ * in the plugin's eager bundle and the jobs store is not.
25
+ *
26
+ * Only an envelope already on the Free plan is touched, and `before` — the
27
+ * envelope held when the read began — guards against overwriting one a door
28
+ * published while the read was in flight, which is newer.
29
+ */ export function refreshAiUsageMeterFree(uid, orgId, credits, before) {
30
+ var _used_account, _used_orgBand, _ref;
31
+ const used = credits == null ? void 0 : credits.used;
32
+ const known = readAiUsageMeter(uid, orgId);
33
+ if (!used || !(known == null ? void 0 : known.mine.free) || known !== before) return;
34
+ const mine = _extends({}, known.mine, {
35
+ used: (_used_account = used.account) != null ? _used_account : known.mine.used
36
+ });
37
+ const pool = {
38
+ used: used.org,
39
+ limit: (_used_orgBand = used.orgBand) != null ? _used_orgBand : known.pool.limit
40
+ };
41
+ const refused = known.refused && ((_ref = credits == null ? void 0 : credits.left) != null ? _ref : 1) <= 0;
42
+ publishAiUsageMeter(uid, orgId, _extends({}, known, {
43
+ mine,
44
+ pool,
45
+ refused,
46
+ state: used.state
47
+ }));
48
+ }
49
+ /**
50
+ * Keep the usage strip current from the jobs list's reads, for as long as the
51
+ * caller (the Assist panel) is mounted. Returns the way to stop.
52
+ */ export function followAiJobsForUsageMeter() {
53
+ const held = new Map();
54
+ return watchAiJobsReads({
55
+ start: (uid, orgId)=>held.set(`${uid}\n${orgId}`, readAiUsageMeter(uid, orgId)),
56
+ done: (uid, orgId, credits)=>{
57
+ var _held_get;
58
+ return refreshAiUsageMeterFree(uid, orgId, credits, (_held_get = held.get(`${uid}\n${orgId}`)) != null ? _held_get : null);
59
+ }
60
+ });
61
+ }
62
+
63
+ //# sourceMappingURL=ai-usage-meter-refresh.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/components/ai-usage-meter-refresh.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AiFreeCreditsLeft } from '../model/ai-site-job'\nimport type { AiUsageMeterWire } from '../usage/ai-usage-wire'\nimport { watchAiJobsReads } from './ai-jobs-store'\nimport { publishAiUsageMeter, readAiUsageMeter } from './use-ai-usage-meter'\n\n/**\n * Bring a Free reader's usage strip up to what the jobs list just read\n * (AGL-3722): a job spends credits between chat messages, and nothing else\n * moves the strip. The warn state comes with the figures, worked out by the\n * route, so the strip's rule is not shipped to the browser twice. Kept out of `use-ai-usage-meter.ts` because that file is\n * in the plugin's eager bundle and the jobs store is not.\n *\n * Only an envelope already on the Free plan is touched, and `before` — the\n * envelope held when the read began — guards against overwriting one a door\n * published while the read was in flight, which is newer.\n */\nexport function refreshAiUsageMeterFree(\n uid: string,\n orgId: string,\n credits: Pick<AiFreeCreditsLeft, 'left' | 'used'> | null | undefined,\n before: AiUsageMeterWire | null,\n): void {\n const used = credits?.used\n const known = readAiUsageMeter(uid, orgId)\n if (!used || !known?.mine.free || known !== before) return\n const mine = { ...known.mine, used: used.account ?? known.mine.used }\n const pool = { used: used.org, limit: used.orgBand ?? known.pool.limit }\n const refused = known.refused && (credits?.left ?? 1) <= 0\n publishAiUsageMeter(uid, orgId, { ...known, mine, pool, refused, state: used.state })\n}\n\n/**\n * Keep the usage strip current from the jobs list's reads, for as long as the\n * caller (the Assist panel) is mounted. Returns the way to stop.\n */\nexport function followAiJobsForUsageMeter(): () => void {\n const held = new Map<string, AiUsageMeterWire | null>()\n return watchAiJobsReads({\n start: (uid, orgId) => held.set(`${uid}\\n${orgId}`, readAiUsageMeter(uid, orgId)),\n done: (uid, orgId, credits) =>\n refreshAiUsageMeterFree(uid, orgId, credits, held.get(`${uid}\\n${orgId}`) ?? null),\n })\n}\n"],"names":["watchAiJobsReads","publishAiUsageMeter","readAiUsageMeter","refreshAiUsageMeterFree","uid","orgId","credits","before","used","known","mine","free","account","pool","org","limit","orgBand","refused","left","state","followAiJobsForUsageMeter","held","Map","start","set","done","get"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAID,SAASA,gBAAgB,QAAQ,qBAAiB;AAClD,SAASC,mBAAmB,EAAEC,gBAAgB,QAAQ,0BAAsB;AAE5E;;;;;;;;;;CAUC,GACD,OAAO,SAASC,wBACdC,GAAW,EACXC,KAAa,EACbC,OAAoE,EACpEC,MAA+B;QAKKC,eACEA;IAJtC,MAAMA,OAAOF,2BAAAA,QAASE,IAAI;IAC1B,MAAMC,QAAQP,iBAAiBE,KAAKC;IACpC,IAAI,CAACG,QAAQ,EAACC,yBAAAA,MAAOC,IAAI,CAACC,IAAI,KAAIF,UAAUF,QAAQ;IACpD,MAAMG,OAAO,aAAKD,MAAMC,IAAI;QAAEF,IAAI,GAAEA,gBAAAA,KAAKI,OAAO,YAAZJ,gBAAgBC,MAAMC,IAAI,CAACF,IAAI;;IACnE,MAAMK,OAAO;QAAEL,MAAMA,KAAKM,GAAG;QAAEC,KAAK,GAAEP,gBAAAA,KAAKQ,OAAO,YAAZR,gBAAgBC,MAAMI,IAAI,CAACE,KAAK;IAAC;IACvE,MAAME,UAAUR,MAAMQ,OAAO,IAAI,SAACX,2BAAAA,QAASY,IAAI,mBAAI,MAAM;IACzDjB,oBAAoBG,KAAKC,OAAO,aAAKI;QAAOC;QAAMG;QAAMI;QAASE,OAAOX,KAAKW,KAAK;;AACpF;AAEA;;;CAGC,GACD,OAAO,SAASC;IACd,MAAMC,OAAO,IAAIC;IACjB,OAAOtB,iBAAiB;QACtBuB,OAAO,CAACnB,KAAKC,QAAUgB,KAAKG,GAAG,CAAC,GAAGpB,IAAI,EAAE,EAAEC,OAAO,EAAEH,iBAAiBE,KAAKC;QAC1EoB,MAAM,CAACrB,KAAKC,OAAOC;gBAC4Be;mBAA7ClB,wBAAwBC,KAAKC,OAAOC,UAASe,YAAAA,KAAKK,GAAG,CAAC,GAAGtB,IAAI,EAAE,EAAEC,OAAO,aAA3BgB,YAAgC;;IACjF;AACF"}
@@ -41,6 +41,7 @@ import { aiJobsActivity, aiJobsLauncherLabel } from "../model/ai-job-activity.js
41
41
  import { AiModelSelector } from "./ai-model-selector.component.js";
42
42
  import { AiUsageStrip } from "./ai-usage-strip.component.js";
43
43
  import { useAiModelChoice } from "./use-ai-model-choice.js";
44
+ import { followAiJobsForUsageMeter } from "./ai-usage-meter-refresh.js";
44
45
  import { usePublishAiUsageMeter } from "./use-ai-usage-meter.js";
45
46
  import { pluginDocsHelp } from "@aglyn/aglyn/app-utils/docs-help";
46
47
  import { HelpTip } from "@aglyn/shared-ui-jsx";
@@ -567,6 +568,9 @@ export function AssistPanelComponent(props) {
567
568
  // plan's `aiGenerative` and their own `ai.generate`, as the drawer's gate.
568
569
  const jobsVisible = Boolean(scopedOrgId) && editRungHint;
569
570
  const inFlight = useAiJobsInFlight(user, scopedOrgId, jobsVisible);
571
+ // The same read tells the usage strip what a Free workspace has left, so
572
+ // jobs that spend between chat messages move it (AGL-3722).
573
+ useEffect(()=>followAiJobsForUsageMeter(), []);
570
574
  const jobsActivity = aiJobsActivity(inFlight);
571
575
  // Any surface asking to open AI jobs — an "Open AI jobs" button, the
572
576
  // indicator, a notification's link — opens the panel on its drawer.