@nauticana/sail 1.1.33 → 1.1.34

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 (34) hide show
  1. package/README.md +17 -11
  2. package/package.json +1 -1
  3. package/src/agent/component/approval_inbox.html +63 -0
  4. package/src/agent/component/approval_inbox.ts +91 -0
  5. package/src/agent/component/decision_timeline.html +4 -4
  6. package/src/agent/component/decision_timeline.ts +2 -0
  7. package/src/agent/component/prompt_section_editor.html +4 -3
  8. package/src/agent/component/prompt_section_editor.ts +12 -5
  9. package/src/agent/component/release_sections.html +1 -1
  10. package/src/agent/component/release_sections.ts +2 -1
  11. package/src/agent/component/studio_page.html +269 -0
  12. package/src/agent/component/studio_page.ts +346 -0
  13. package/src/agent/component/studio_tab.ts +14 -0
  14. package/src/agent/component/turn_event_list.html +9 -0
  15. package/src/agent/index.ts +6 -0
  16. package/src/agent/model/agent_studio.ts +122 -9
  17. package/src/agent/model/approval.ts +42 -0
  18. package/src/agent/model/conversation.ts +9 -1
  19. package/src/agent/model/decision.ts +2 -1
  20. package/src/agent/model/turn_event.ts +10 -0
  21. package/src/agent/service/agent_studio.service.ts +36 -1
  22. package/src/agent/service/approval.service.ts +18 -0
  23. package/src/agent/util/release_diff.ts +33 -0
  24. package/src/agent/util/studio_errors.ts +11 -0
  25. package/src/agent/util/turn_timeline.ts +7 -1
  26. package/src/component/login/account_deletion.html +17 -4
  27. package/src/component/login/account_deletion.ts +35 -28
  28. package/src/component/login/social_login.ts +6 -5
  29. package/src/index.ts +1 -0
  30. package/src/model/auth.ts +17 -2
  31. package/src/service/auth.service.ts +10 -4
  32. package/src/service/caption_pipe.ts +26 -0
  33. package/src/service/label.service.ts +5 -4
  34. package/src/service/rest_url.ts +1 -0
package/README.md CHANGED
@@ -264,7 +264,7 @@ Default page size is 100, capped at 1000 server-side. Sail maps `limit`, `offset
264
264
 
265
265
  ## Backend-driven constant captions
266
266
 
267
- `LabelService` loads one keel `constant_value` domain from the backend-provided `ApplicationData.ConstantCache` and caches its value-to-caption map. 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.
267
+ `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.
268
268
 
269
269
  ```typescript
270
270
  this.labels.loadLabels('ride_type').subscribe(() => {
@@ -684,7 +684,7 @@ A complete screen is the pieces above assembled over `BillingService` — no met
684
684
 
685
685
  ## Agent UI (v1.1.20)
686
686
 
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.
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.
688
688
 
689
689
  ```ts
690
690
  import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@nauticana/sail/agent';
@@ -692,7 +692,10 @@ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@
692
692
 
693
693
  | Export | Purpose |
694
694
  |---|---|
695
- | `AgentStudioService` | Client for the thirteen `studio-v2` routes under `/api/agent-studio/` (fixed, unversioned paths). Models mirror the backend's JSON names. |
695
+ | `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
+ | `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. |
698
+ | `studioErrorMessage(err)` | One message for a failed Studio call: the conflict cause, the field errors, or the detail. |
696
699
  | `studioConflictCause(err)` | `'revision'` (reload the draft), `'sealed'` (a layer sits under a sealed one) or `'other'` for a `409`. |
697
700
  | `studioFieldErrors(err)` / `retryAfterSeconds(err)` | Field errors of a `400`; `Retry-After` of a `429`/`503`. |
698
701
  | `sail-prompt-language-editor` | One language's prompt sections. Emits the whole language on every edit — a save must send every layer back, because an editable layer missing from the request ends its binding. Emits `resetRequested` with the scope, section, and language to reset. |
@@ -705,7 +708,7 @@ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@
705
708
  | `ConversationService` | Transport for Scout's conversation bridge: `submit`, `stream(requestId, turnStream)`, `cancel`. Mount point injected through `CONVERSATION_BASE_PATH`. |
706
709
  | `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. |
707
710
 
708
- Map your audit rows onto `DecisionRecord` to feed the timeline. Style the `.prompt-*`, `.release-*`, `.turn-*`, and `.decision-*` class hooks.
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.
709
712
 
710
713
  ### Conversation transport (v1.1.22)
711
714
 
@@ -719,7 +722,7 @@ providers: [{ provide: CONVERSATION_BASE_PATH, useValue: '/api/wingmate/' }]
719
722
 
720
723
  ```ts
721
724
  const stream = new TurnStream(requestId);
722
- this.conversation.submit({ request_id: requestId, conversation_id: conversationId, agent_id: 'wingmate', input })
725
+ this.conversation.submit({ request_id: requestId, conversation_id: conversationId, agent_type: 'wingmate', input })
723
726
  .pipe(switchMap(() => this.conversation.stream(requestId, stream)), takeUntilDestroyed())
724
727
  .subscribe({ error: (err) => this.onStreamError(err) });
725
728
  ```
@@ -1016,7 +1019,7 @@ Config required in `SAIL_GUI_CONFIG`:
1016
1019
  }
1017
1020
  ```
1018
1021
 
1019
- Under the hood, the component calls `BaseAuthService.loginSocial(provider, idToken, consent)` → `POST /public/login/social` and emits `loginSuccess: LoginResponseSocial`. The session is completed automatically (token stored, app data loaded, routes initialized).
1022
+ Under the hood, the component calls `BaseAuthService.loginSocial(provider, idToken, consent)` → `POST /public/login/social` and emits `loginSuccess: LoginResponseSocial`. Apple sends the user's name only on the first authorization and never in the ID token, so that sign-in carries it as `socialName: {firstName?, lastName?}` for the app to pre-fill its profile. The session is completed automatically (token stored, app data loaded, routes initialized).
1020
1023
 
1021
1024
  For backward compatibility, the older OAuth-code flow `BaseAuthService.loginWithGoogle(code)` (hits `/public/login/google`) is still supported; prefer `loginSocial` for new code.
1022
1025
 
@@ -1383,14 +1386,16 @@ These are App Store / Play Store compliance primitives from keel. All are opt-in
1383
1386
 
1384
1387
  ### Re-authentication gate
1385
1388
 
1386
- The four sensitive endpoints below require **recent re-authentication** before they will run — pass `password` and/or `twoFactorCode` to prove the user is still present at the keyboard. The shipped components already capture this; only callers using the service methods directly need to do this.
1389
+ The four sensitive endpoints below require **recent re-authentication** before they will run — pass `password`, `twoFactorCode` or `reauthCode` to prove the user is still present at the keyboard. The shipped components already capture this; only callers using the service methods directly need to do this.
1387
1390
 
1388
1391
  | Method | Required re-auth |
1389
1392
  |--------|-----------------|
1390
1393
  | `setup2FA(reauth)` | `password` (or `twoFactorCode` when re-rotating) |
1391
1394
  | `disable2FA(password, code)` | **both** `password` and current TOTP `code` |
1392
- | `deleteAccount(reauth, reason?)` | `password` (or `twoFactorCode`) |
1393
- | `logoutEverywhere(reauth)` | `password` (or `twoFactorCode`) |
1395
+ | `deleteAccount(reauth, reason?)` | `password`, `twoFactorCode` or `reauthCode` |
1396
+ | `logoutEverywhere(reauth)` | `password`, `twoFactorCode` or `reauthCode` |
1397
+
1398
+ An account without a password re-authenticates with `reauthCode`, a one-time code sent by `sendReauthCode(channel?)` (`POST /api/user/reauth/send`; no channel prefers email).
1394
1399
 
1395
1400
  The `ReauthCredentials` type is exported as a shared shape:
1396
1401
  ```typescript
@@ -1402,10 +1407,10 @@ auth.deleteAccount(reauth, 'Closing my account.').subscribe();
1402
1407
  ### Account deletion
1403
1408
 
1404
1409
  ```html
1405
- <sail-account-deletion [confirmationText]="'DELETE'"></sail-account-deletion>
1410
+ <sail-account-deletion [confirmationText]="'DELETE'" reauthMethod="code"></sail-account-deletion>
1406
1411
  ```
1407
1412
 
1408
- Typed-confirmation UX: the destructive button is disabled until the user types the exact `confirmationText` (default `DELETE`) **and** confirms their password. Optional reason textarea is forwarded to the backend. On success, local session is cleared and the router navigates to `config.accountDeletedRoute` (default `/login/local`).
1413
+ Typed-confirmation UX: the destructive button is disabled until the user types the exact `confirmationText` (default `DELETE`) **and** re-authenticates. `reauthMethod` (`password` default, or `code`) picks the initial method; the user can switch, and the code path sends, resends and submits a `reauthCode`. Optional reason textarea is forwarded to the backend. On success, local session is cleared and the router navigates to `config.accountDeletedRoute` (default `/login/local`).
1409
1414
 
1410
1415
  ### Logout everywhere
1411
1416
 
@@ -1433,6 +1438,7 @@ Platform codes: `'I'` iOS, `'A'` Android, `'W'` Web. The token acquisition (Capa
1433
1438
  |----------|---------------|
1434
1439
  | `DELETE /api/user/account` | `deleteAccount(reauth, reason?)` |
1435
1440
  | `POST /api/user/logout-everywhere` | `logoutEverywhere(reauth)` |
1441
+ | `POST /api/user/reauth/send` | `sendReauthCode(channel?)` |
1436
1442
  | `POST /api/push/register` | `registerPushToken(platform, token, appVersion?, deviceModel?)` |
1437
1443
  | `POST /api/push/revoke` | `revokePushToken(token)` |
1438
1444
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.33",
3
+ "version": "1.1.34",
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": {
@@ -0,0 +1,63 @@
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>
5
+ }
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
+ <button matButton type="button" [disabled]="loading()" (click)="load()">Refresh</button>
20
+ </section>
21
+
22
+ @if (selected(); as request) {
23
+ <section class="approval-inbox__decision" aria-label="Decision">
24
+ <h3>{{ request.summary || request.action }}</h3>
25
+ <dl>
26
+ <dt>Action</dt><dd>{{ request.action }}</dd>
27
+ <dt>Resource</dt><dd>{{ request.resource }}</dd>
28
+ <dt>Effect</dt><dd>{{ request.output_class | sailCaption:'agent_output_class' }}</dd>
29
+ <dt>Risk tier</dt><dd>{{ request.risk_tier }}</dd>
30
+ <dt>Required by</dt><dd>{{ request.rule_source | sailCaption:'approval_rule_source' }} · {{ request.rule_id }}</dd>
31
+ @if (request.approver_reference; as approver) {
32
+ <dt>Assigned to</dt><dd>{{ approver.kind | sailCaption:'approval_approver_kind' }} {{ approver.ref }} (step {{ request.route_step }})</dd>
33
+ }
34
+ @if (request.deadline_at) {
35
+ <dt>Deadline</dt><dd>{{ request.deadline_at | date: 'medium' }}</dd>
36
+ }
37
+ @if (request.revises_step_id) {
38
+ <dt>Revises</dt><dd>step {{ request.revises_step_id }}</dd>
39
+ }
40
+ <dt>Requested</dt><dd>{{ request.created_at | date: 'medium' }}</dd>
41
+ </dl>
42
+
43
+ <mat-form-field>
44
+ <mat-label>Reason (optional)</mat-label>
45
+ <textarea matInput rows="2" [value]="reason()" (input)="reason.set($any($event.target).value)"></textarea>
46
+ </mat-form-field>
47
+ <mat-form-field>
48
+ <mat-label>Revised tool arguments (JSON object, for Edit)</mat-label>
49
+ <textarea matInput rows="4" [value]="revisedArguments()" (input)="revisedArguments.set($any($event.target).value)"></textarea>
50
+ </mat-form-field>
51
+
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>
56
+ </div>
57
+ </section>
58
+ }
59
+
60
+ @if (errorMessage()) {
61
+ <p class="approval-inbox__error" role="alert">{{ errorMessage() }}</p>
62
+ }
63
+ </div>
@@ -0,0 +1,91 @@
1
+ import { DatePipe } from '@angular/common';
2
+ import { ChangeDetectionStrategy, Component, OnInit, computed, inject, output, signal } from '@angular/core';
3
+ import { MatButtonModule } from '@angular/material/button';
4
+ import { MatFormFieldModule } from '@angular/material/form-field';
5
+ import { MatInputModule } from '@angular/material/input';
6
+ import { BaseAsync } from '../../component/abstract/base_async';
7
+ import { CaptionPipe } from '../../service/caption_pipe';
8
+ import { ApprovalRequest, ApprovalVerdict } from '../model/approval';
9
+ import { ApprovalService } from '../service/approval.service';
10
+
11
+ /**
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.
14
+ * Selector: <sail-approval-inbox>.
15
+ */
16
+ @Component({
17
+ selector: 'sail-approval-inbox',
18
+ changeDetection: ChangeDetectionStrategy.OnPush,
19
+ imports: [DatePipe, MatButtonModule, MatFormFieldModule, MatInputModule, CaptionPipe],
20
+ templateUrl: './approval_inbox.html',
21
+ })
22
+ export class ApprovalInboxComponent extends BaseAsync implements OnInit {
23
+ private readonly approvals = inject(ApprovalService);
24
+
25
+ readonly decided = output<ApprovalRequest>();
26
+
27
+ readonly requests = signal<ApprovalRequest[]>([]);
28
+ readonly selectedKey = signal('');
29
+ readonly reason = signal('');
30
+ readonly revisedArguments = signal('');
31
+ readonly selected = computed(() => this.requests().find((r) => this.key(r) === this.selectedKey()));
32
+
33
+ ngOnInit(): void {
34
+ this.load();
35
+ }
36
+
37
+ 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.');
42
+ }
43
+
44
+ select(request: ApprovalRequest | undefined): void {
45
+ this.selectedKey.set(request ? this.key(request) : '');
46
+ this.reason.set('');
47
+ this.revisedArguments.set('');
48
+ }
49
+
50
+ decide(verdict: ApprovalVerdict): void {
51
+ const request = this.selected();
52
+ if (!request) return;
53
+ let revised: Record<string, unknown> | undefined;
54
+ if (verdict === 'E') {
55
+ revised = parseObject(this.revisedArguments());
56
+ if (!revised) {
57
+ this.errorMessage.set('Revised arguments must be a JSON object.');
58
+ return;
59
+ }
60
+ }
61
+ this.run(this.approvals.resolve({
62
+ request_id: request.request_id,
63
+ execution_step_id: request.execution_step_id,
64
+ verdict,
65
+ proposed_digest: request.proposed_digest,
66
+ reason: this.reason().trim() || undefined,
67
+ revised_arguments: revised,
68
+ }), (result) => {
69
+ this.requests.update((rows) => rows.filter((r) => this.key(r) !== this.key(request)));
70
+ this.select(this.requests()[0]);
71
+ this.decided.emit(result);
72
+ }, 'Failed to record the decision.', (err) => {
73
+ if ((err as { status?: number })?.status !== 409) return;
74
+ this.load();
75
+ this.errorMessage.set('This request was reassigned or decided elsewhere; the list was refreshed.');
76
+ });
77
+ }
78
+
79
+ key(request: ApprovalRequest): string {
80
+ return `${request.request_id}#${request.execution_step_id}`;
81
+ }
82
+ }
83
+
84
+ function parseObject(text: string): Record<string, unknown> | undefined {
85
+ try {
86
+ const value: unknown = JSON.parse(text);
87
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
@@ -5,14 +5,14 @@
5
5
  @for (record of group.records; track record.id) {
6
6
  <li
7
7
  class="decision-record"
8
- [class.decision-record--allow]="record.outcome === 'allow'"
9
- [class.decision-record--deny]="record.outcome === 'deny'"
10
- [class.decision-record--pending]="record.outcome === 'pending'"
8
+ [class.decision-record--allow]="record.outcome === 'A'"
9
+ [class.decision-record--deny]="record.outcome === 'D'"
10
+ [class.decision-record--pending]="record.outcome === 'P'"
11
11
  >
12
12
  <time class="decision-record__time" [attr.datetime]="record.occurred_at">{{ record.occurred_at }}</time>
13
13
  <span class="decision-record__action">{{ record.action }}</span>
14
14
  <span class="decision-record__resource">{{ record.resource }}</span>
15
- <span class="decision-record__outcome">{{ record.outcome }}</span>
15
+ <span class="decision-record__outcome">{{ record.outcome | sailCaption:'audit_decision_outcome' }}</span>
16
16
  <span class="decision-record__principal">{{ record.principal.kind }} {{ record.principal.id }}</span>
17
17
  @if (record.reason) {
18
18
  <p class="decision-record__reason">{{ record.reason }}</p>
@@ -1,4 +1,5 @@
1
1
  import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
2
+ import { CaptionPipe } from '../../service/caption_pipe';
2
3
  import { DecisionCategoryGroup, DecisionRecord } from '../model/decision';
3
4
 
4
5
  /**
@@ -8,6 +9,7 @@ import { DecisionCategoryGroup, DecisionRecord } from '../model/decision';
8
9
  @Component({
9
10
  selector: 'sail-decision-timeline',
10
11
  changeDetection: ChangeDetectionStrategy.OnPush,
12
+ imports: [CaptionPipe],
11
13
  templateUrl: './decision_timeline.html',
12
14
  })
13
15
  export class DecisionTimelineComponent {
@@ -37,8 +37,9 @@
37
37
  <mat-form-field>
38
38
  <mat-label>Merge mode</mat-label>
39
39
  <mat-select formControlName="merge_mode">
40
- <mat-option value="append">Append</mat-option>
41
- <mat-option value="replace">Replace</mat-option>
40
+ @for (mode of mergeModes(); track mode.code) {
41
+ <mat-option [value]="mode.code">{{ mode.caption }}</mat-option>
42
+ }
42
43
  </mat-select>
43
44
  </mat-form-field>
44
45
  <mat-form-field>
@@ -64,7 +65,7 @@
64
65
  } @else {
65
66
  <dl class="prompt-layer__readonly">
66
67
  <dt>Merge mode</dt>
67
- <dd>{{ row.layer.merge_mode }}</dd>
68
+ <dd>{{ row.layer.merge_mode | sailCaption:'config_merge_mode' }}</dd>
68
69
  <dt>Instruction</dt>
69
70
  <dd class="prompt-layer__text">{{ row.layer.instruction }}</dd>
70
71
  <dt>Output</dt>
@@ -1,5 +1,5 @@
1
- import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal, untracked } from '@angular/core';
2
- import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
1
+ import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal, untracked } from '@angular/core';
2
+ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
3
3
  import { FormArray, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors } from '@angular/forms';
4
4
  import { MatButtonModule } from '@angular/material/button';
5
5
  import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -7,6 +7,9 @@ import { MatFormFieldModule } from '@angular/material/form-field';
7
7
  import { MatIconModule } from '@angular/material/icon';
8
8
  import { MatInputModule } from '@angular/material/input';
9
9
  import { MatSelectModule } from '@angular/material/select';
10
+ import { map } from 'rxjs';
11
+ import { CaptionPipe } from '../../service/caption_pipe';
12
+ import { LabelOption, LabelService } from '../../service/label.service';
10
13
  import { AgentMergeMode, AgentPromptLayer, AgentPromptSection } from '../model/agent_studio';
11
14
 
12
15
  type LayerForm = FormGroup<{
@@ -25,7 +28,7 @@ export type EditableLayerSlot = 'type' | 'agent';
25
28
 
26
29
  function layerContentRequired(group: LayerForm): ValidationErrors | null {
27
30
  const { instruction, output, merge_mode } = group.getRawValue();
28
- if (merge_mode === 'replace') return instruction.trim() ? null : { instructionRequired: true };
31
+ if (merge_mode === 'R') return instruction.trim() ? null : { instructionRequired: true };
29
32
  return instruction.trim() || output.trim() ? null : { contentRequired: true };
30
33
  }
31
34
 
@@ -37,7 +40,7 @@ function layerContentRequired(group: LayerForm): ValidationErrors | null {
37
40
  @Component({
38
41
  selector: 'sail-prompt-section-editor',
39
42
  changeDetection: ChangeDetectionStrategy.OnPush,
40
- imports: [ReactiveFormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule],
43
+ imports: [ReactiveFormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule, CaptionPipe],
41
44
  templateUrl: './prompt_section_editor.html',
42
45
  })
43
46
  export class PromptSectionEditorComponent {
@@ -49,6 +52,10 @@ export class PromptSectionEditorComponent {
49
52
  readonly sectionChange = output<AgentPromptSection>();
50
53
  readonly validChange = output<boolean>();
51
54
 
55
+ private readonly labels = inject(LabelService);
56
+ /** A prompt layer appends or replaces; intersect applies to other settings only. */
57
+ protected readonly mergeModes = toSignal(this.labels.loadLabels('config_merge_mode').pipe(
58
+ map(() => this.labels.options('config_merge_mode').filter((o) => o.code !== 'I'))), { initialValue: [] as LabelOption[] });
52
59
  protected readonly forms = new FormArray<LayerForm>([]);
53
60
  protected readonly rows = signal<LayerRow[]>([]);
54
61
  private emitted?: AgentPromptSection;
@@ -95,7 +102,7 @@ export class PromptSectionEditorComponent {
95
102
  const layer: AgentPromptLayer = {
96
103
  scope_id: slot === 'type' ? this.typeScopeId() : this.agentScopeId(),
97
104
  scope_kind: '',
98
- merge_mode: 'append',
105
+ merge_mode: 'A',
99
106
  sealed: false,
100
107
  editable: true,
101
108
  instruction: '',
@@ -6,7 +6,7 @@
6
6
  <h4 class="release-section__caption">{{ section.caption }}</h4>
7
7
  <p class="release-section__source">
8
8
  Decided by {{ section.source.scope_kind }} scope {{ section.source.scope_id }}
9
- ({{ section.source.merge_mode }})
9
+ ({{ section.source.merge_mode | sailCaption:'config_merge_mode' }})
10
10
  @if (section.source.sealed) {
11
11
  <span class="release-section__sealed">
12
12
  <mat-icon aria-hidden="true">lock</mat-icon>
@@ -1,5 +1,6 @@
1
1
  import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
2
2
  import { MatIconModule } from '@angular/material/icon';
3
+ import { CaptionPipe } from '../../service/caption_pipe';
3
4
  import { AgentReleaseSection } from '../model/agent_studio';
4
5
 
5
6
  interface ReleaseLanguageGroup {
@@ -14,7 +15,7 @@ interface ReleaseLanguageGroup {
14
15
  @Component({
15
16
  selector: 'sail-release-sections',
16
17
  changeDetection: ChangeDetectionStrategy.OnPush,
17
- imports: [MatIconModule],
18
+ imports: [MatIconModule, CaptionPipe],
18
19
  templateUrl: './release_sections.html',
19
20
  })
20
21
  export class ReleaseSectionsComponent {