@nauticana/sail 1.1.34 → 1.1.36

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 (47) hide show
  1. package/README.md +23 -16
  2. package/package.json +1 -1
  3. package/src/agent/component/approval_inbox.html +93 -22
  4. package/src/agent/component/approval_inbox.ts +199 -17
  5. package/src/agent/component/assistant_activity.html +28 -0
  6. package/src/agent/component/assistant_activity.ts +40 -0
  7. package/src/agent/component/studio_page.html +35 -1
  8. package/src/agent/component/studio_page.ts +67 -10
  9. package/src/agent/component/turn_thread.html +27 -0
  10. package/src/agent/component/turn_thread.ts +94 -0
  11. package/src/agent/index.ts +2 -0
  12. package/src/agent/model/agent_studio.ts +67 -1
  13. package/src/agent/model/approval.ts +45 -1
  14. package/src/agent/model/conversation.ts +35 -1
  15. package/src/agent/model/decision.ts +20 -8
  16. package/src/agent/service/agent_studio.service.ts +29 -0
  17. package/src/agent/service/approval.service.ts +24 -4
  18. package/src/agent/service/conversation.service.ts +10 -0
  19. package/src/component/agency/agency_accept.ts +2 -8
  20. package/src/component/agency/agency_earnings_summary.html +3 -3
  21. package/src/component/agency/agency_earnings_summary.ts +2 -2
  22. package/src/component/agency/agency_payout_history.html +1 -1
  23. package/src/component/agency/agency_payout_history.ts +2 -3
  24. package/src/component/agency/commission_ledger.html +1 -1
  25. package/src/component/agency/commission_ledger.ts +2 -3
  26. package/src/component/billing/plan_selector.ts +1 -1
  27. package/src/component/billing/price_selector.ts +5 -5
  28. package/src/component/oauth_connection/oauth_connection_panel.ts +2 -1
  29. package/src/component/payout/bank_info_form.html +15 -6
  30. package/src/component/payout/bank_info_form.ts +28 -72
  31. package/src/component/security/api_keys.html +85 -0
  32. package/src/component/security/api_keys.ts +96 -0
  33. package/src/component/security/connected_apps.html +25 -0
  34. package/src/component/security/connected_apps.ts +40 -0
  35. package/src/index.ts +7 -0
  36. package/src/model/access.ts +37 -0
  37. package/src/model/appdata.ts +41 -67
  38. package/src/model/geo.ts +12 -0
  39. package/src/service/access.service.ts +56 -0
  40. package/src/service/auth.service.ts +2 -1
  41. package/src/service/caption_pipe.ts +8 -2
  42. package/src/service/currency.service.ts +51 -0
  43. package/src/service/money_pipe.ts +27 -0
  44. package/src/service/rest_service.ts +1 -1
  45. package/src/service/rest_url.ts +3 -0
  46. package/src/util/errors.ts +5 -4
  47. package/src/util/money.ts +18 -34
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A shared Angular component library for building CRUD-based admin frontends. Provides table management, form handling, navigation, authentication, two-factor authentication, and trusted device management — all driven by metadata from a [keel](https://github.com/nauticana/keel) Go backend.
4
4
 
5
- > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.33 adopts keel v1.2.106 (`prompt` on the login page, new-device step-up, sign-in sessions), sail v1.1.32 adopts keel v1.2.101 (the OAuth connect callback ticket), sail v1.1.31 adopts keel v1.2.100 (tenant single sign-on, DNS and HTTP-file domain verification), sail v1.1.30 adopts keel v1.2.99 (password routes; a password change signs out), sail v1.1.29 adopts keel v1.2.94 (signup: confirmation signs in, partner setup, sign-in hand-off code), sail v1.1.28 adopts keel v1.2.93 (absolute `return` for the OAuth hand-off), sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
5
+ > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.36 adopts keel v1.2.113 (API key expiry, caller-aware scope policy), sail v1.1.35 adopts keel v1.2.112 (minor-unit money, the `currency` catalog, OAuth grant routes), sail v1.1.33 adopts keel v1.2.106 (`prompt` on the login page, new-device step-up, sign-in sessions), sail v1.1.32 adopts keel v1.2.101 (the OAuth connect callback ticket), sail v1.1.31 adopts keel v1.2.100 (tenant single sign-on, DNS and HTTP-file domain verification), sail v1.1.30 adopts keel v1.2.99 (password routes; a password change signs out), sail v1.1.29 adopts keel v1.2.94 (signup: confirmation signs in, partner setup, sign-in hand-off code), sail v1.1.28 adopts keel v1.2.93 (absolute `return` for the OAuth hand-off), sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
6
6
 
7
7
  **Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows. `passwordPolicyValidator` + `BaseAuthService.ensurePasswordPolicy()` — validate passwords against keel's policy (via `SailGuiConfig.passwordPolicyUrl`); see Configuration reference.
8
8
 
@@ -16,7 +16,7 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
16
16
  | **Form components** | `DynamicField`, `RecordForm`, `TableForm` |
17
17
  | **Navigation** | `Navigation` (sidenav + toolbar with menu, responsive) |
18
18
  | **Login** | `LoginComponent`, `RegisterComponent`, `ChpassComponent`, `ConfirmRegisterComponent`, `ConfirmChpassComponent` |
19
- | **Security** | `TwoFactorSetupComponent`, `TwoFactorVerifyComponent`, `TrustedDevicesComponent`, `SessionsComponent`, `AccountDeletionComponent` |
19
+ | **Security** | `TwoFactorSetupComponent`, `TwoFactorVerifyComponent`, `TrustedDevicesComponent`, `SessionsComponent`, `ApiKeysComponent`, `ConnectedAppsComponent`, `AccountDeletionComponent` |
20
20
  | **Account** | `MyAccountComponent` (self-service hub), `ProfileEditorComponent` (name/locale immediate; email/phone verify-before-apply) |
21
21
  | **Auth** | `ConsentGateComponent`, `OtpInputComponent`, `SocialLoginComponent`, `SsoLoginComponent`, `SsoReturnComponent` |
22
22
  | **Billing** | `PlanSelectorComponent`, `PriceSelectorComponent`, `CheckoutButtonComponent`, `PaymentMethodsComponent`, `PortalButtonComponent`, `UsageMeterComponent`, `StatusChipComponent`, `TrialBannerComponent`, `SeatSelectorComponent`, `DunningBannerComponent` |
@@ -29,9 +29,9 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
29
29
  | **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `AgencyService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `PartnerDocumentService`, `NotificationInboxService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
30
30
  | **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `AbstractEditableView`, `BaseAsync`, `BaseRestService` |
31
31
  | **Config** | `SAIL_GUI_CONFIG`, `SailGuiConfig`, `configureRestUrls()` |
32
- | **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `TableActionParameter`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, `InboxMessage`/`InboxPage`, `PartnerDocument`/`DocumentUploadRequest`, 2FA types, etc. |
32
+ | **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `TableActionParameter`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `Currency`, `Country`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, `InboxMessage`/`InboxPage`, `PartnerDocument`/`DocumentUploadRequest`, 2FA types, etc. |
33
33
  | **Decorators** | `@IsSailString()`, `@IsSailNumeric()` (class-validator based) |
34
- | **Utils** | `titleCase()`, `fromPriceLabel()`, `formatMinorCurrency()` |
34
+ | **Utils** | `titleCase()`, `fromPriceLabel()`, `formatMinor()`, `CurrencyService`, `sailMoney` pipe |
35
35
 
36
36
  ## Quick start
37
37
 
@@ -262,6 +262,10 @@ this.backend.listPaginated<MyRow>('orders', { Status: 'open' }, {
262
262
 
263
263
  Default page size is 100, capped at 1000 server-side. Sail maps `limit`, `offset`, and `orderBy` to keel's `_limit`, `_offset`, and `_order` query controls. Existing callers that put those string controls in the filter map remain compatible; explicit `PageRequest` values take precedence.
264
264
 
265
+ ## Money
266
+
267
+ keel stores money as integer minor units and serves the `currency` table (`code`, `caption`, `exponent`) as the only source of exponents. `formatMinor(minor, currency, exponent)` renders an amount; `Intl` supplies the symbol and grouping only. Billing payloads (`PlanPrice`, `Subscription`, `Invoice`) carry `exponent` beside their `…Minor` fields, so they render without a catalog: `{{ invoice.totalMinor | sailMoney:invoice.currency:invoice.exponent }}`. Amounts without an exponent, such as the agency ledger, use `{{ minor | sailMoney:currency }}`: the pipe loads the catalog once through `CurrencyService` (generic REST `currency`, so the role needs `TABLE SELECT currency`), renders `''` until it arrives, and throws for a code the catalog lacks rather than guessing decimals.
268
+
265
269
  ## Backend-driven constant captions
266
270
 
267
271
  `LabelService` loads one caption map from the backend-provided `ApplicationData`: a keel `constant_value` domain from `ConstantCache` by constant id, or a catalog table registered in `foreign_key_lookup` from `TableCache` by table name. `sailCaption` is the pipe form: `{{ code | sailCaption:'constant_id' }}`. The domain is the backend `constant_id` (for example `ride_type`), not a locale: keel's constant catalogue does not contain a language column. It does not call generic `constant_value` CRUD, which is normally restricted to administrative roles.
@@ -410,6 +414,8 @@ The login flow redirects `twoFactorRequired` responses to `/login/2fa`. With `tw
410
414
 
411
415
  `SessionsComponent` (`<sail-sessions>`) lists the sessions with their `sign_in_method` captions, marks the current one, and signs out any other.
412
416
 
417
+ `ApiKeysComponent` (`<sail-api-keys [scopes]>`) lists the partner's keys and uses the backend-provided `generate` and `restrict` table actions; controls appear only for granted actions. Pass the scopes the app offers (`LabelOption[]`) after filtering out scopes the caller may not mint; with none, keel's `ScopePolicy` supplies them. A key may carry an expiry, shown in its own column. The role also needs generic `SELECT` and `DELETE` grants to list and revoke keys. `ConnectedAppsComponent` (`<sail-connected-apps>`) lists and disconnects OAuth clients through keel's `GrantHandler` routes (v1.2.112, `RestURL.oauthGrantsURL` and `oauthGrantRevokeURL`).
418
+
413
419
  ### Cross-origin cookie requirement
414
420
 
415
421
  The "trust this device" feature (keel v0.9+) is backed by a server-set HttpOnly cookie named `keel_td`. The lifecycle is entirely server-driven:
@@ -498,9 +504,8 @@ unless the user holds `PARTNER_PLAN_SUBSCRIPTION` `CANCEL` / `CHANGE` / `PORTAL`
498
504
  </sail-price-selector>
499
505
  ```
500
506
 
501
- `<sail-price-selector>` is presentational (a `radiogroup` of offers), formats each amount with `Intl` from the offer's currency (so JPY/BHD render with the right number of decimals), and maps PERIOD_TYPE codes to copy via `[cycleLabels]`. It emits the full `PlanPrice` so you keep the chosen terms, not just the id.
507
+ `<sail-price-selector>` is presentational (a `radiogroup` of offers), formats each `amountMinor` at the offer's `exponent`, and maps PERIOD_TYPE codes to copy via `[cycleLabels]`. It emits the full `PlanPrice` so you keep the chosen terms, not just the id.
502
508
 
503
- > **Migration.** The flat `PublicPlan.priceId` is deprecated (keel v1.0.5 dropped `subscription_plan.provider_price_id`) — read the id from the selected offer (`offer().priceId`). **keel v1.0.7 also removed `PublicPlan.monthlyCost`/`annualCost`** from both `/public/plans` and the billing catalog, so any plan-card code reading them now gets `undefined`. Derive a display price from the offers with `fromPriceLabel(plan.prices)` (used by `<sail-plan-selector>`), or render the per-offer amounts via `<sail-price-selector>`. These fields are removed from sail's `PublicPlan` type so the compiler flags any remaining references.
504
509
 
505
510
  **Checkout button** — calls `createCheckout()` and redirects to the provider-hosted URL. Feed it the chosen offer's `priceId`:
506
511
  ```html
@@ -684,7 +689,7 @@ A complete screen is the pieces above assembled over `BillingService` — no met
684
689
 
685
690
  ## Agent UI (v1.1.20)
686
691
 
687
- Agent Studio and agent-conversation UI live in a secondary entry point, `@nauticana/sail/agent`. Nothing in it is exported from `@nauticana/sail`, so an app that never imports it bundles none of it. It needs no additional peer dependency. The wire types match Scout v1.2.8.
692
+ Agent Studio and agent-conversation UI live in a secondary entry point, `@nauticana/sail/agent`. Nothing in it is exported from `@nauticana/sail`, so an app that never imports it bundles none of it. It needs no additional peer dependency. The wire types match Scout v1.2.14.
688
693
 
689
694
  ```ts
690
695
  import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@nauticana/sail/agent';
@@ -694,7 +699,10 @@ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@
694
699
  |---|---|
695
700
  | `AgentStudioService` | Client for Scout's core Studio routes under `/api/agent-studio/` (fixed, unversioned paths): the `features` manifest, list, draft, settings, create, rename, archive, enable, test, publish, restore, reset, default, history, audit, release sections, models. Models mirror the backend's JSON names. |
696
701
  | `sail-studio-page` | Embeddable Studio: an agent rail grouped by `[groupBy]` (agent type by default; pass a function for workflow or position), and Overview, Prompts, Settings (scoped bindings and what they resolve to), Test, and History (diff, restore, audit) tabs. Product tabs are `<ng-template sailStudioTab="id" label="Label" let-agent>`. A control shows only when the `agents` feature lists its action and the caller holds the `AGENT_STUDIO` grant at `agent:<name>` (`agent_type:<type>` for create); hiding is never the authorization. `hasUnsavedChanges()` serves a `canDeactivate` guard. |
697
- | `sail-approval-inbox` / `ApprovalService` | The signed-in person's pending approvals (`GET /api/approval/pending`) and a decision view: approve, reject, or edit a tool call's arguments (`POST /api/approval/resolve`). A `409` reloads the list; `(decided)` emits the result. |
702
+ | `sail-studio-page` Guardrails tab | Shown when the `features` manifest lists `guardrails`: inherited layers read-only, the agent's own rules as an editable JSON array saved against `expected_agent_revision`, and its safety events paged by `before`. Edit needs the feature's `EDIT` action. The rail captions Scout's fixed `AgentReadiness` values (`AGENT_READINESS_CAPTIONS`). |
703
+ | `sail-assistant-activity` | The tenant's decision records from Scout's `decisions` feature (`GET /api/agent-studio/decisions`, `AGENT_AUDIT` `VIEW`), newest first and paged by `next_before`: when, action, resource, outcome caption, who, app, and whom it acted for, named by the product's Scout `Labeler` (kind and id, or the client ref, without one). `[category]` and `[outcome]` filter. |
704
+ | `sail-turn-thread` | The signed-in person's turns from `GET {base}turns` (`ConversationService.turns`), newest first, paged by `next_before` and optionally narrowed to `[workRef]`: `turn_status` caption, settled cost in minor units with its exponent, agent and release version. Each `task_kind` renders its result through `<ng-template sailTurnResult="kind" let-turn>` (`"*"` for any other kind; `let-turn` is a `ConversationTurn`); a failed turn without error text shows `[failedText]`; the first page reloads every `[pollMs]` while a turn is nonterminal. |
705
+ | `sail-approval-inbox` / `ApprovalService` | The signed-in person's pending approvals (`GET /api/approval/pending`) and MCP confirmations (`GET /api/approval/confirmations`) grouped as waiting, submitted, needing reconciliation and recent; each list loads independently and a `404` leaves it empty. Approvals are approved, rejected, or edited (`POST /api/approval/resolve`); confirmations are confirmed, declined, withdrawn, or reconciled as executed or failed through `/api/approval/confirmation/*`, each control shown only when Scout says the person may. Approving asks once more; reconciling requires a note of up to 500 characters. `[open]` selects an item by key or confirmation id (an emailed link). People, workspaces and apps show their Scout `Labeler` captions. A tool's preview renders through `<ng-template sailApprovalPreview="tool" let-c>` (`"*"` for any other tool), otherwise as JSON. A `404` or `409` reloads the list; `(decided)` and `(confirmationChanged)` emit the result. |
698
706
  | `studioErrorMessage(err)` | One message for a failed Studio call: the conflict cause, the field errors, or the detail. |
699
707
  | `studioConflictCause(err)` | `'revision'` (reload the draft), `'sealed'` (a layer sits under a sealed one) or `'other'` for a `409`. |
700
708
  | `studioFieldErrors(err)` / `retryAfterSeconds(err)` | Field errors of a `400`; `Retry-After` of a `429`/`503`. |
@@ -705,10 +713,10 @@ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@
705
713
  | `TurnStream` | Per-turn stream state: `accept(frame)` accepts Scout's zero-based frames and drops frames already rendered, `cursor()` is the next sequence wanted for inclusive replay, a suspension frame (`approval_pending`, even when marked final) sets `awaiting_approval` without advancing the cursor so the resumed frame at the same sequence is accepted, `markReplayExpired()` + `acceptStoredResult(frame)` handle an expired replay window, `markCancelRequested()` holds `cancelling` until the final frame. |
706
714
  | `sail-turn-stream-view` | `TurnStream` + event list + status + cancel button (`cancelRequested`). |
707
715
  | `sail-decision-timeline` | Read-only decision chain of one request, grouped by category. |
708
- | `ConversationService` | Transport for Scout's conversation bridge: `submit`, `stream(requestId, turnStream)`, `cancel`. Mount point injected through `CONVERSATION_BASE_PATH`. |
716
+ | `ConversationService` | Transport for Scout's conversation bridge: `submit`, `stream(requestId, turnStream)`, `cancel`. Mount point injected through `CONVERSATION_BASE_PATH`; the service is a root singleton, so provide the token at bootstrap, or both together on a component. |
709
717
  | `SseParser` | Incremental `text/event-stream` parser: `push(chunk)` returns whole events out of chunks that split anywhere, including between the CR and LF of a CRLF; `finish()` flushes what the closing body left pending. |
710
718
 
711
- Map your audit rows onto `DecisionRecord` (outcome `A`, `D`, or `P`, captioned from `audit_decision_outcome`) to feed the timeline. Style the `.prompt-*`, `.release-*`, `.turn-*`, `.decision-*`, `.studio__*`, and `.approval-inbox__*` class hooks. Scout's short codes render through `sailCaption`, a pipe over the backend's constant captions.
719
+ `DecisionRecord` is Scout's `api.DecisionRecord` (outcome `A`, `D`, or `P`, captioned from `audit_decision_outcome`); the timeline takes the records of one request, the activity table pages the tenant's. Style the `.prompt-*`, `.release-*`, `.turn-*`, `.decision-*`, `.studio__*`, and `.approval-inbox__*` class hooks. Scout's short codes render through `sailCaption`, a pipe over the backend's constant captions.
712
720
 
713
721
  ### Conversation transport (v1.1.22)
714
722
 
@@ -816,10 +824,9 @@ pending profile. The ledger and payout-history components show default empty
816
824
  messages; pass `[emptyMessage]="''"` when a composed page should omit empty
817
825
  regions entirely.
818
826
 
819
- All accounting values are integer minor units. `formatMinorCurrency()` uses the
820
- currency exponent reported by `Intl.NumberFormat`, so zero- and three-decimal
821
- currencies render correctly; earnings stay separated by currency rather than
822
- being summed into a misleading total.
827
+ All accounting values are integer minor units rendered through the `sailMoney` pipe at the
828
+ exponent of keel's `currency` catalog (see [Money](#money)); earnings stay separated by
829
+ currency rather than being summed into a misleading total.
823
830
 
824
831
  **Delegation roles (v1.1.27, keel v1.2.89).** `AgencyDelegation.roles` (always present) and
825
832
  `AgencyClient.roles` carry `AgencyRoleGrant { role, expiresAt? }`; no `expiresAt` is
@@ -1607,10 +1614,10 @@ The v0.6 / v0.7 line introduced three additive feature groups against keel v0.7.
1607
1614
  |---|---|---|
1608
1615
  | `@nauticana/sail` | `PayoutService` | keel/payout API client — onboarding, reuse, status, bank-version replacement, beneficiary registration |
1609
1616
  | `@nauticana/sail` | `PayoutProviderOnboardingComponent` (`<sail-payout-provider-onboarding>`) | Drop-in onboarding step with reuse picker, hosted-KYC handoff, and asynchronous-confirmation output |
1610
- | `@nauticana/sail` | `PayoutBankInfoFormComponent` (`<sail-payout-bank-info-form>`) | Tax + payout details form (country / currency / tax ID / billing address / agreement) |
1617
+ | `@nauticana/sail` | `PayoutBankInfoFormComponent` (`<sail-payout-bank-info-form>`) | Tax + payout details form; countries from keel's `country` table, tax-id types from the `tax_id_type` domain |
1611
1618
  | `@nauticana/sail` | `UserPaymentMethodService` | Saved-card list / remove / set-default API client |
1612
1619
  | `@nauticana/sail` | `UserPaymentMethodsComponent` (`<sail-user-payment-methods>`) | List of saved cards/wallets with set-default + remove |
1613
- | `@nauticana/sail` | `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `UserPaymentMethod`, `DEFAULT_COUNTRY_PROFILES` | Model types / defaults |
1620
+ | `@nauticana/sail` | `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `UserPaymentMethod` | Model types |
1614
1621
 
1615
1622
  ### 2. New keel endpoints
1616
1623
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.34",
3
+ "version": "1.1.36",
4
4
  "description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
5
5
  "main": "src/index.ts",
6
6
  "exports": {
@@ -1,27 +1,42 @@
1
1
  <div class="approval-inbox">
2
- <section class="approval-inbox__list" aria-label="Pending approvals">
3
- @if (requests().length === 0 && !loading()) {
4
- <p class="approval-inbox__empty">Nothing is waiting for your decision.</p>
2
+ <nav class="approval-inbox__list" aria-label="Approvals">
3
+ @for (section of sections(); track section.title) {
4
+ <section [attr.aria-label]="section.title">
5
+ <h3>{{ section.title }} ({{ section.requests.length + section.confirmations.length }})</h3>
6
+ <ul>
7
+ @for (request of section.requests; track key(request)) {
8
+ <li>
9
+ <button type="button" class="approval-inbox__item" [class.selected]="key(request) === selectedKey()"
10
+ [attr.aria-current]="key(request) === selectedKey()" (click)="select(key(request))">
11
+ <span class="approval-inbox__summary">{{ request.summary || request.action }}</span>
12
+ @if (request.deadline_at) {
13
+ <span class="approval-inbox__deadline">Due {{ request.deadline_at | date: 'medium' }}</span>
14
+ }
15
+ </button>
16
+ </li>
17
+ }
18
+ @for (c of section.confirmations; track confirmationKey(c)) {
19
+ <li>
20
+ <button type="button" class="approval-inbox__item" [class.selected]="confirmationKey(c) === selectedKey()"
21
+ [attr.aria-current]="confirmationKey(c) === selectedKey()" (click)="select(confirmationKey(c))">
22
+ <span class="approval-inbox__summary">{{ c.tool }} · {{ c.status | sailCaption:'mcp_confirmation_status' }}</span>
23
+ @if (c.status === 'P') {
24
+ <span class="approval-inbox__deadline">Expires {{ c.expires_at | date: 'medium' }}</span>
25
+ }
26
+ </button>
27
+ </li>
28
+ }
29
+ </ul>
30
+ </section>
31
+ } @empty {
32
+ @if (!loading()) { <p class="approval-inbox__empty">Nothing is waiting for your decision.</p> }
5
33
  }
6
- <ul>
7
- @for (request of requests(); track key(request)) {
8
- <li>
9
- <button type="button" class="approval-inbox__item" [class.selected]="key(request) === selectedKey()"
10
- [attr.aria-current]="key(request) === selectedKey()" (click)="select(request)">
11
- <span class="approval-inbox__summary">{{ request.summary || request.action }}</span>
12
- @if (request.deadline_at) {
13
- <span class="approval-inbox__deadline">Due {{ request.deadline_at | date: 'medium' }}</span>
14
- }
15
- </button>
16
- </li>
17
- }
18
- </ul>
19
34
  <button matButton type="button" [disabled]="loading()" (click)="load()">Refresh</button>
20
- </section>
35
+ </nav>
21
36
 
22
37
  @if (selected(); as request) {
23
38
  <section class="approval-inbox__decision" aria-label="Decision">
24
- <h3>{{ request.summary || request.action }}</h3>
39
+ <h3 #heading tabindex="-1">{{ request.summary || request.action }}</h3>
25
40
  <dl>
26
41
  <dt>Action</dt><dd>{{ request.action }}</dd>
27
42
  <dt>Resource</dt><dd>{{ request.resource }}</dd>
@@ -49,10 +64,65 @@
49
64
  <textarea matInput rows="4" [value]="revisedArguments()" (input)="revisedArguments.set($any($event.target).value)"></textarea>
50
65
  </mat-form-field>
51
66
 
52
- <div class="approval-inbox__actions">
53
- <button matButton="elevated" type="button" [disabled]="loading()" (click)="decide('A')">Approve</button>
54
- <button matButton type="button" class="warn" [disabled]="loading()" (click)="decide('X')">Reject</button>
55
- <button matButton type="button" [disabled]="loading() || !revisedArguments().trim()" (click)="decide('E')">Edit and run</button>
67
+ <div class="approval-inbox__actions" role="group" aria-label="Decide">
68
+ @if (confirming()) {
69
+ <span>Approve this action? It runs exactly as shown.</span>
70
+ <button #yes matButton="filled" type="button" [disabled]="loading()" (click)="decide('A')">Yes, approve</button>
71
+ <button matButton type="button" [disabled]="loading()" (click)="cancelApprove()">Cancel</button>
72
+ } @else {
73
+ <button matButton="filled" type="button" [disabled]="loading()" (click)="askApprove()">Approve</button>
74
+ <button matButton type="button" class="warn" [disabled]="loading()" (click)="decide('X')">Reject</button>
75
+ <button matButton type="button" [disabled]="loading() || !revisedArguments().trim()" (click)="decide('E')">Edit and run</button>
76
+ }
77
+ </div>
78
+ </section>
79
+ }
80
+
81
+ @if (selectedConfirmation(); as c) {
82
+ <section class="approval-inbox__decision" aria-label="Confirmation">
83
+ <h3 #heading tabindex="-1">{{ c.tool }}</h3>
84
+ <dl>
85
+ <dt>Workspace</dt><dd>{{ c.tenant_label || c.tenant_id }}</dd>
86
+ <dt>Status</dt><dd>{{ c.status | sailCaption:'mcp_confirmation_status' }}@if (c.expired) { (expired) }</dd>
87
+ <dt>Requested by</dt><dd>{{ name(c.maker) }}@if (c.client_label || c.client_ref) { through {{ c.client_label || c.client_ref }} }</dd>
88
+ <dt>Requested</dt><dd>{{ c.created_at | date: 'medium' }} · expires {{ c.expires_at | date: 'medium' }}</dd>
89
+ @if (c.approval_required) { <dt>Maker-checker</dt><dd>A second person must approve</dd> }
90
+ @if (c.decider; as d) { <dt>Decided</dt><dd>{{ name(d) }} · {{ c.decided_at | date: 'medium' }} {{ c.decision_note }}</dd> }
91
+ @if (c.completed_at) { <dt>Finished</dt><dd>{{ c.completed_at | date: 'medium' }}</dd> }
92
+ @if (c.reconciler; as r) { <dt>Reconciled</dt><dd>{{ name(r) }} {{ c.reconcile_note }}</dd> }
93
+ @if (c.error) { <dt>Error</dt><dd>{{ c.error }}</dd> }
94
+ </dl>
95
+ @if (previewTemplate(c.tool); as template) {
96
+ <ng-container [ngTemplateOutlet]="template" [ngTemplateOutletContext]="{ $implicit: c }" />
97
+ } @else if (c.preview !== undefined) {
98
+ <pre class="approval-inbox__preview">{{ c.preview | json }}</pre>
99
+ }
100
+
101
+ @if (c.can_decide || c.can_reconcile) {
102
+ <mat-form-field>
103
+ <mat-label>{{ c.can_reconcile ? 'What you verified (required)' : 'Note (optional)' }}</mat-label>
104
+ <textarea matInput rows="2" [maxLength]="noteMax" [required]="c.can_reconcile" [value]="reason()"
105
+ (input)="reason.set($any($event.target).value)"></textarea>
106
+ </mat-form-field>
107
+ }
108
+ <div class="approval-inbox__actions" role="group" aria-label="Decide">
109
+ @if (c.can_decide) {
110
+ @if (confirming()) {
111
+ <span>Confirm this action? It runs exactly as shown.</span>
112
+ <button #yes matButton="filled" type="button" [disabled]="loading()" (click)="confirm(true)">Yes, confirm</button>
113
+ <button matButton type="button" [disabled]="loading()" (click)="cancelApprove()">Cancel</button>
114
+ } @else {
115
+ <button matButton="filled" type="button" [disabled]="loading()" (click)="askApprove()">Confirm</button>
116
+ <button matButton type="button" class="warn" [disabled]="loading()" (click)="confirm(false)">Decline</button>
117
+ }
118
+ }
119
+ @if (c.can_withdraw) {
120
+ <button matButton type="button" [disabled]="loading()" (click)="withdraw()">Withdraw</button>
121
+ }
122
+ @if (c.can_reconcile) {
123
+ <button matButton type="button" [disabled]="loading() || !reason().trim()" (click)="reconcile('D')">Mark executed</button>
124
+ <button matButton type="button" [disabled]="loading() || !reason().trim()" (click)="reconcile('F')">Mark failed</button>
125
+ }
56
126
  </div>
57
127
  </section>
58
128
  }
@@ -60,4 +130,5 @@
60
130
  @if (errorMessage()) {
61
131
  <p class="approval-inbox__error" role="alert">{{ errorMessage() }}</p>
62
132
  }
133
+ <p class="approval-inbox__status" role="status">{{ successMessage() }}</p>
63
134
  </div>
@@ -1,50 +1,142 @@
1
- import { DatePipe } from '@angular/common';
2
- import { ChangeDetectionStrategy, Component, OnInit, computed, inject, output, signal } from '@angular/core';
1
+ import { DatePipe, JsonPipe, NgTemplateOutlet } from '@angular/common';
2
+ import {
3
+ ChangeDetectionStrategy, Component, Directive, ElementRef, Injector, OnInit, Signal, TemplateRef, afterNextRender,
4
+ computed, contentChildren, inject, input, output, signal, viewChild,
5
+ } from '@angular/core';
3
6
  import { MatButtonModule } from '@angular/material/button';
4
7
  import { MatFormFieldModule } from '@angular/material/form-field';
5
8
  import { MatInputModule } from '@angular/material/input';
9
+ import { Observable, catchError, forkJoin, of } from 'rxjs';
6
10
  import { BaseAsync } from '../../component/abstract/base_async';
7
11
  import { CaptionPipe } from '../../service/caption_pipe';
8
- import { ApprovalRequest, ApprovalVerdict } from '../model/approval';
12
+ import { errorDetail, httpStatus } from '../../util/errors';
13
+ import {
14
+ ApprovalRequest, ApprovalVerdict, CONFIRMATION_NOTE_MAX, Confirmation, ReconcileOutcome,
15
+ } from '../model/approval';
16
+ import { principalName } from '../model/decision';
9
17
  import { ApprovalService } from '../service/approval.service';
10
18
 
19
+ export interface ApprovalPreviewContext {
20
+ $implicit: Confirmation;
21
+ }
22
+
11
23
  /**
12
- * The signed-in person's pending approvals and a decision view: approve, reject, or edit a
13
- * tool call's arguments. A 409 (reassigned or decided elsewhere) reloads the list.
24
+ * How one tool's confirmation preview reads, or every other tool's with `"*"`:
25
+ * `<ng-template sailApprovalPreview="tool" let-c>…</ng-template>`. Without one the preview prints as JSON.
26
+ */
27
+ @Directive({ selector: 'ng-template[sailApprovalPreview]' })
28
+ export class ApprovalPreviewDirective {
29
+ readonly tool = input.required<string>({ alias: 'sailApprovalPreview' });
30
+ readonly template = inject<TemplateRef<ApprovalPreviewContext>>(TemplateRef);
31
+
32
+ static ngTemplateContextGuard(_dir: ApprovalPreviewDirective, ctx: unknown): ctx is ApprovalPreviewContext {
33
+ return true;
34
+ }
35
+ }
36
+
37
+ export interface ApprovalInboxSection {
38
+ title: string;
39
+ requests: ApprovalRequest[];
40
+ confirmations: Confirmation[];
41
+ }
42
+
43
+ const actionErrors: Record<number, string> = {
44
+ 403: 'You are not allowed to do this here.',
45
+ 404: 'This item no longer exists, or you can no longer see it; the list was refreshed.',
46
+ 409: 'This item changed or was decided elsewhere; the list was refreshed.',
47
+ };
48
+
49
+ /**
50
+ * The signed-in person's pending approvals and MCP confirmations, grouped as waiting, submitted,
51
+ * needing reconciliation and recent, with a decision view: approve, reject, or edit a tool call's
52
+ * arguments; confirm, decline, withdraw, or reconcile a confirmation. Approving asks once more, and
53
+ * reconciling requires a note. A list the backend does not serve (404) stays empty.
14
54
  * Selector: <sail-approval-inbox>.
15
55
  */
16
56
  @Component({
17
57
  selector: 'sail-approval-inbox',
18
58
  changeDetection: ChangeDetectionStrategy.OnPush,
19
- imports: [DatePipe, MatButtonModule, MatFormFieldModule, MatInputModule, CaptionPipe],
59
+ imports: [DatePipe, JsonPipe, NgTemplateOutlet, MatButtonModule, MatFormFieldModule, MatInputModule, CaptionPipe],
20
60
  templateUrl: './approval_inbox.html',
21
61
  })
22
62
  export class ApprovalInboxComponent extends BaseAsync implements OnInit {
23
63
  private readonly approvals = inject(ApprovalService);
64
+ private readonly injector = inject(Injector);
65
+ private readonly previews = contentChildren(ApprovalPreviewDirective);
66
+ private readonly heading = viewChild<ElementRef<HTMLElement>>('heading');
67
+ private readonly yes = viewChild<ElementRef<HTMLElement>>('yes');
68
+ private opened = '';
69
+
70
+ /** The item to open first: an approval or confirmation key, or a confirmation id from a link. */
71
+ readonly open = input('');
24
72
 
25
73
  readonly decided = output<ApprovalRequest>();
74
+ readonly confirmationChanged = output<Confirmation>();
26
75
 
76
+ readonly noteMax = CONFIRMATION_NOTE_MAX;
77
+ readonly name = principalName;
27
78
  readonly requests = signal<ApprovalRequest[]>([]);
79
+ readonly confirmations = signal<Confirmation[]>([]);
80
+ readonly sections = computed(() => groupInbox(this.requests(), this.confirmations()));
28
81
  readonly selectedKey = signal('');
29
82
  readonly reason = signal('');
30
83
  readonly revisedArguments = signal('');
84
+ /** The approve step awaits its second click. */
85
+ readonly confirming = signal(false);
31
86
  readonly selected = computed(() => this.requests().find((r) => this.key(r) === this.selectedKey()));
87
+ readonly selectedConfirmation = computed(() => this.confirmations().find((c) => this.confirmationKey(c) === this.selectedKey()));
32
88
 
33
89
  ngOnInit(): void {
34
90
  this.load();
35
91
  }
36
92
 
37
93
  load(): void {
38
- this.run(this.approvals.pending(), (rows) => {
39
- this.requests.set(rows);
40
- if (!this.selected()) this.select(rows[0]);
41
- }, 'Failed to load approvals.');
94
+ this.run(forkJoin([
95
+ this.listed(this.approvals.pending(), this.requests(), 'Failed to load approvals.'),
96
+ this.listed(this.approvals.confirmations(), this.confirmations(), 'Failed to load confirmations.'),
97
+ ]), ([requests, confirmations]) => {
98
+ this.requests.set(requests);
99
+ this.confirmations.set(confirmations);
100
+ const link = this.open();
101
+ if (link && link !== this.opened) {
102
+ const linked = requests.find((r) => this.key(r) === link)
103
+ ?? confirmations.find((c) => this.confirmationKey(c) === link || String(c.id) === link);
104
+ if (linked) {
105
+ this.opened = link;
106
+ this.select('request_id' in linked ? this.key(linked) : this.confirmationKey(linked));
107
+ this.focus(this.heading);
108
+ return;
109
+ }
110
+ if (!this.successMessage()) {
111
+ this.successMessage.set('The linked item is not listed: it may be decided, withdrawn or expired, or not yours to act on.');
112
+ }
113
+ }
114
+ if (!this.selected() && !this.selectedConfirmation()) {
115
+ this.selectFirst();
116
+ }
117
+ });
42
118
  }
43
119
 
44
- select(request: ApprovalRequest | undefined): void {
45
- this.selectedKey.set(request ? this.key(request) : '');
120
+ select(key: string): void {
121
+ this.selectedKey.set(key);
46
122
  this.reason.set('');
47
123
  this.revisedArguments.set('');
124
+ this.confirming.set(false);
125
+ }
126
+
127
+ askApprove(): void {
128
+ this.confirming.set(true);
129
+ this.focus(this.yes);
130
+ }
131
+
132
+ cancelApprove(): void {
133
+ this.confirming.set(false);
134
+ this.focus(this.heading);
135
+ }
136
+
137
+ previewTemplate(tool: string): TemplateRef<ApprovalPreviewContext> | undefined {
138
+ const previews = this.previews();
139
+ return (previews.find((p) => p.tool() === tool) ?? previews.find((p) => p.tool() === '*'))?.template;
48
140
  }
49
141
 
50
142
  decide(verdict: ApprovalVerdict): void {
@@ -58,7 +150,7 @@ export class ApprovalInboxComponent extends BaseAsync implements OnInit {
58
150
  return;
59
151
  }
60
152
  }
61
- this.run(this.approvals.resolve({
153
+ this.act(this.approvals.resolve({
62
154
  request_id: request.request_id,
63
155
  execution_step_id: request.execution_step_id,
64
156
  verdict,
@@ -67,18 +159,108 @@ export class ApprovalInboxComponent extends BaseAsync implements OnInit {
67
159
  revised_arguments: revised,
68
160
  }), (result) => {
69
161
  this.requests.update((rows) => rows.filter((r) => this.key(r) !== this.key(request)));
70
- this.select(this.requests()[0]);
162
+ this.selectFirst();
71
163
  this.decided.emit(result);
72
- }, 'Failed to record the decision.', (err) => {
73
- if ((err as { status?: number })?.status !== 409) return;
164
+ return verdict === 'X' ? 'Rejected.' : 'Approved.';
165
+ });
166
+ }
167
+
168
+ confirm(approve: boolean): void {
169
+ const c = this.selectedConfirmation();
170
+ if (!c) return;
171
+ this.applyConfirmation(this.approvals.decideConfirmation(c, approve, this.reason().trim()),
172
+ approve ? 'Confirmed; the action runs shortly.' : 'Declined; nothing was done.');
173
+ }
174
+
175
+ withdraw(): void {
176
+ const c = this.selectedConfirmation();
177
+ if (!c || !window.confirm('Withdraw this confirmation?')) return;
178
+ this.act(this.approvals.withdrawConfirmation(c), () => {
74
179
  this.load();
75
- this.errorMessage.set('This request was reassigned or decided elsewhere; the list was refreshed.');
180
+ return 'Withdrawn; nothing will be done.';
76
181
  });
77
182
  }
78
183
 
184
+ reconcile(outcome: ReconcileOutcome): void {
185
+ const c = this.selectedConfirmation();
186
+ const note = this.reason().trim();
187
+ if (!c || !note) return;
188
+ this.applyConfirmation(this.approvals.reconcileConfirmation(c, outcome, note), 'Outcome recorded; the action was not run again.');
189
+ }
190
+
79
191
  key(request: ApprovalRequest): string {
80
192
  return `${request.request_id}#${request.execution_step_id}`;
81
193
  }
194
+
195
+ confirmationKey(c: Confirmation): string {
196
+ return `${c.tenant_id}/${c.id}`;
197
+ }
198
+
199
+ private applyConfirmation(request: Observable<Confirmation>, done: string): void {
200
+ this.act(request, (result) => {
201
+ this.confirmations.update((rows) => rows.map((c) => c.id === result.id && c.tenant_id === result.tenant_id ? result : c));
202
+ this.confirmationChanged.emit(result);
203
+ return done;
204
+ });
205
+ }
206
+
207
+ private act<T>(request: Observable<T>, done: (result: T) => string): void {
208
+ this.run(request, (result) => {
209
+ this.reason.set('');
210
+ this.confirming.set(false);
211
+ this.successMessage.set(done(result));
212
+ this.focus(this.heading);
213
+ }, 'Failed to record the decision.', (err) => {
214
+ const status = httpStatus(err);
215
+ const message = actionErrors[status ?? 0];
216
+ if (!message) return;
217
+ if (status !== 403) this.load();
218
+ this.errorMessage.set(message);
219
+ });
220
+ }
221
+
222
+ private listed<T>(list: Observable<T[]>, current: T[], failed: string): Observable<T[]> {
223
+ return list.pipe(catchError((err) => {
224
+ if (httpStatus(err) === 404) return of([]);
225
+ this.errorMessage.set(errorDetail(err, failed));
226
+ return of(current);
227
+ }));
228
+ }
229
+
230
+ private focus(target: Signal<ElementRef<HTMLElement> | undefined>): void {
231
+ afterNextRender(() => target()?.nativeElement.focus(), { injector: this.injector });
232
+ }
233
+
234
+ private selectFirst(): void {
235
+ const first = this.sections()[0];
236
+ this.select(first?.requests[0] ? this.key(first.requests[0])
237
+ : first?.confirmations[0] ? this.confirmationKey(first.confirmations[0]) : '');
238
+ }
239
+ }
240
+
241
+ /** Approvals wait first; confirmations land in the first section they qualify for, open ones soonest expiry first. */
242
+ export function groupInbox(requests: ApprovalRequest[], confirmations: Confirmation[]): ApprovalInboxSection[] {
243
+ const sections: ApprovalInboxSection[] = [
244
+ { title: 'Waiting for your decision', requests, confirmations: [] },
245
+ { title: 'Submitted by you', requests: [], confirmations: [] },
246
+ { title: 'Needs reconciliation', requests: [], confirmations: [] },
247
+ { title: 'Recent', requests: [], confirmations: [] },
248
+ ];
249
+ for (const c of confirmations) {
250
+ sections[c.can_decide ? 0 : c.can_withdraw ? 1 : c.can_reconcile ? 2 : 3].confirmations.push(c);
251
+ }
252
+ sections.forEach((s, i) => s.confirmations.sort(i < 2
253
+ ? (a, b) => time(a.expires_at) - time(b.expires_at)
254
+ : (a, b) => lastActivity(b) - lastActivity(a)));
255
+ return sections.filter((s) => s.requests.length || s.confirmations.length);
256
+ }
257
+
258
+ function time(value: string | undefined): number {
259
+ return (value && Date.parse(value)) || 0;
260
+ }
261
+
262
+ function lastActivity(c: Confirmation): number {
263
+ return Math.max(time(c.created_at), time(c.decided_at), time(c.completed_at));
82
264
  }
83
265
 
84
266
  function parseObject(text: string): Record<string, unknown> | undefined {
@@ -0,0 +1,28 @@
1
+ <div class="assistant-activity">
2
+ @if (records().length > 0) {
3
+ <table class="assistant-activity__table">
4
+ <thead>
5
+ <tr><th>When</th><th>Action</th><th>Resource</th><th>Outcome</th><th>By</th><th>App</th><th>Acting for</th></tr>
6
+ </thead>
7
+ <tbody>
8
+ @for (record of records(); track record.id) {
9
+ <tr [class]="'assistant-activity__row assistant-activity__row--' + record.outcome">
10
+ <td>{{ record.occurred_at | date: 'medium' }}</td>
11
+ <td><code>{{ record.action }}</code></td>
12
+ <td>{{ record.resource }}</td>
13
+ <td>{{ record.outcome | sailCaption:'audit_decision_outcome' }}@if (record.reason) { <span class="assistant-activity__reason">({{ record.reason }})</span> }</td>
14
+ <td>{{ name(record.principal) }}</td>
15
+ <td>{{ record.client_label || record.client_ref }}</td>
16
+ <td>@if (record.authority; as a) { {{ name(a.subject) }} }</td>
17
+ </tr>
18
+ }
19
+ </tbody>
20
+ </table>
21
+ } @else if (!loading()) {
22
+ <p class="assistant-activity__empty">No assistant activity yet.</p>
23
+ }
24
+ @if (nextBefore()) {
25
+ <button matButton type="button" [disabled]="loading()" (click)="load(nextBefore())">Load more</button>
26
+ }
27
+ @if (errorMessage()) { <p class="assistant-activity__error" role="alert">{{ errorMessage() }}</p> }
28
+ </div>
@@ -0,0 +1,40 @@
1
+ import { DatePipe } from '@angular/common';
2
+ import { ChangeDetectionStrategy, Component, OnInit, inject, input, signal } from '@angular/core';
3
+ import { MatButtonModule } from '@angular/material/button';
4
+ import { BaseAsync } from '../../component/abstract/base_async';
5
+ import { CaptionPipe } from '../../service/caption_pipe';
6
+ import { DecisionOutcome, DecisionRecord, principalName } from '../model/decision';
7
+ import { AgentStudioService } from '../service/agent_studio.service';
8
+
9
+ /**
10
+ * What assistants did in the tenant: Scout's decision records (`decisions` feature, `AGENT_AUDIT`
11
+ * `VIEW`), newest first and paged by `next_before`, named by the product's labeler.
12
+ * Selector: <sail-assistant-activity>.
13
+ */
14
+ @Component({
15
+ selector: 'sail-assistant-activity',
16
+ changeDetection: ChangeDetectionStrategy.OnPush,
17
+ imports: [DatePipe, MatButtonModule, CaptionPipe],
18
+ templateUrl: './assistant_activity.html',
19
+ })
20
+ export class AssistantActivityComponent extends BaseAsync implements OnInit {
21
+ private readonly studio = inject(AgentStudioService);
22
+
23
+ readonly category = input('');
24
+ readonly outcome = input<DecisionOutcome | undefined>(undefined);
25
+ readonly name = principalName;
26
+
27
+ readonly records = signal<DecisionRecord[]>([]);
28
+ readonly nextBefore = signal(0);
29
+
30
+ ngOnInit(): void {
31
+ this.load();
32
+ }
33
+
34
+ load(before = 0): void {
35
+ this.run(this.studio.decisions(before, 0, this.category(), this.outcome()), (page) => {
36
+ this.records.update((rows) => before ? [...rows, ...page.records] : page.records);
37
+ this.nextBefore.set(page.next_before ?? 0);
38
+ }, 'Failed to load assistant activity.');
39
+ }
40
+ }