@nauticana/sail 1.1.19 → 1.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,6 +60,8 @@ Since sail ships raw TypeScript source, you need a path mapping so the Angular c
60
60
  }
61
61
  ```
62
62
 
63
+ Apps that use the agent entry point (see [Agent UI](#agent-ui-v1120)) add a second mapping, `"@nauticana/sail/agent": ["./node_modules/@nauticana/sail/src/agent/index"]`. The package exposes only these two entry points; deep imports such as `@nauticana/sail/service/...` are not supported.
64
+
63
65
  > **Note:** If your tsconfig has `"baseUrl": "src"`, use `"../node_modules/@nauticana/sail/src/index"` instead (paths resolve relative to `baseUrl`).
64
66
 
65
67
  Suppress class-validator CommonJS warnings in `angular.json`:
@@ -627,6 +629,29 @@ A complete screen is the pieces above assembled over `BillingService` — no met
627
629
  <!-- invoices: render listInvoices() rows directly -->
628
630
  ```
629
631
 
632
+ ## Agent UI (v1.1.20)
633
+
634
+ 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.
635
+
636
+ ```ts
637
+ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@nauticana/sail/agent';
638
+ ```
639
+
640
+ | Export | Purpose |
641
+ |---|---|
642
+ | `AgentStudioService` | Client for the thirteen `studio-v2` routes under `/api/agent-studio/` (fixed, unversioned paths). Models mirror the backend's JSON names. |
643
+ | `studioConflictCause(err)` | `'revision'` (reload the draft), `'sealed'` (a layer sits under a sealed one) or `'other'` for a `409`. |
644
+ | `studioFieldErrors(err)` / `retryAfterSeconds(err)` | Field errors of a `400`; `Retry-After` of a `429`/`503`. |
645
+ | `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. |
646
+ | `sail-prompt-section-editor` | One section's layers in server order. Read-only layers render as text; editable ones edit `instruction`, `output`, `merge_mode`, `sealed`. A sealed layer locks every layer below it. Effective text is shown as received, never merged client-side; after a save, replace the whole draft with the response. |
647
+ | `sail-release-sections` | Frozen sections of a release with the layer (`source`) that decided each. |
648
+ | `sail-turn-event-list` | Renders typed turn events in frame order; proposal, result, effect, and approval of one call meet by `call_id`. Effect status `unknown` is its own state. Unknown kinds are ignored; `extension` events render only through `[extensionTemplate]`. |
649
+ | `TurnStream` | Per-turn stream state: `accept(frame)` drops frames already rendered, `cursor()` is the resume point, `markReplayExpired()` + `acceptStoredResult(frame)` handle an expired replay window, `markCancelRequested()` holds `cancelling` until the final frame. |
650
+ | `sail-turn-stream-view` | `TurnStream` + event list + status + cancel button (`cancelRequested`). |
651
+ | `sail-decision-timeline` | Read-only decision chain of one request, grouped by category. |
652
+
653
+ Transport is app-owned: map your reply frames onto `TurnReplyFrame` and your audit rows onto `DecisionRecord`, then feed them in. Style the `.prompt-*`, `.release-*`, `.turn-*`, and `.decision-*` class hooks.
654
+
630
655
  ## Agency / reseller UI (v1.1.14)
631
656
 
632
657
  sail v1.1.14 supplies composable, unstyled agency components over keel v1.2.41.
package/package.json CHANGED
@@ -1,8 +1,16 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.19",
3
+ "version": "1.1.20",
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
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./agent": "./src/agent/index.ts",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "sideEffects": [
12
+ "./src/model/decorator.ts"
13
+ ],
6
14
  "scripts": {
7
15
  "typecheck": "tsc --noEmit",
8
16
  "check:templates": "ngc -p tsconfig.json",
@@ -0,0 +1,35 @@
1
+ @for (group of groups(); track group.category) {
2
+ <section class="decision-group" [attr.aria-label]="group.category">
3
+ <h3 class="decision-group__category">{{ group.category }}</h3>
4
+ <ol class="decision-group__records">
5
+ @for (record of group.records; track record.id) {
6
+ <li
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'"
11
+ >
12
+ <time class="decision-record__time" [attr.datetime]="record.occurred_at">{{ record.occurred_at }}</time>
13
+ <span class="decision-record__action">{{ record.action }}</span>
14
+ <span class="decision-record__resource">{{ record.resource }}</span>
15
+ <span class="decision-record__outcome">{{ record.outcome }}</span>
16
+ <span class="decision-record__principal">{{ record.principal.kind }} {{ record.principal.id }}</span>
17
+ @if (record.reason) {
18
+ <p class="decision-record__reason">{{ record.reason }}</p>
19
+ }
20
+ @if (record.authority; as authority) {
21
+ <p class="decision-record__authority">
22
+ Acting for {{ authority.subject.kind }} {{ authority.subject.id }}
23
+ @if (authority.grant_id) {
24
+ under grant {{ authority.grant_id }}
25
+ @if (authority.grantor; as grantor) {
26
+ from {{ grantor.kind }} {{ grantor.id }}
27
+ }
28
+ }
29
+ </p>
30
+ }
31
+ </li>
32
+ }
33
+ </ol>
34
+ </section>
35
+ }
@@ -0,0 +1,26 @@
1
+ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
2
+ import { DecisionCategoryGroup, DecisionRecord } from '../model/decision';
3
+
4
+ /**
5
+ * Read-only decision chain of one request, oldest first and grouped by category. Records are
6
+ * shown exactly as supplied — never de-duplicated. Selector: <sail-decision-timeline>.
7
+ */
8
+ @Component({
9
+ selector: 'sail-decision-timeline',
10
+ changeDetection: ChangeDetectionStrategy.OnPush,
11
+ templateUrl: './decision_timeline.html',
12
+ })
13
+ export class DecisionTimelineComponent {
14
+ readonly records = input.required<readonly DecisionRecord[]>();
15
+
16
+ protected readonly groups = computed<DecisionCategoryGroup[]>(() => {
17
+ const chronological = [...this.records()].sort((a, b) => a.occurred_at.localeCompare(b.occurred_at));
18
+ const groups = new Map<string, DecisionRecord[]>();
19
+ for (const record of chronological) {
20
+ const group = groups.get(record.category);
21
+ if (group) group.push(record);
22
+ else groups.set(record.category, [record]);
23
+ }
24
+ return [...groups].map(([category, records]) => ({ category, records }));
25
+ });
26
+ }
@@ -0,0 +1,29 @@
1
+ <div class="prompt-language">
2
+ <fieldset class="prompt-language__reset">
3
+ <legend>Reset prompts</legend>
4
+ <mat-form-field>
5
+ <mat-label>Reset to</mat-label>
6
+ <mat-select [formControl]="resetScope">
7
+ <mat-option value="agent_override">Remove agent overrides</mat-option>
8
+ <mat-option value="type_default">Remove type defaults</mat-option>
9
+ <mat-option value="platform_baseline">Platform baseline</mat-option>
10
+ </mat-select>
11
+ </mat-form-field>
12
+ <mat-checkbox [formControl]="everyLanguage">Every language</mat-checkbox>
13
+ <button matButton type="button" [disabled]="disabled()" (click)="requestReset(0)">Reset all sections</button>
14
+ </fieldset>
15
+
16
+ @for (section of language().prompt_sections; track section.prompt_header_id) {
17
+ <sail-prompt-section-editor
18
+ [section]="section"
19
+ [agentScopeId]="agentScopeId()"
20
+ [typeScopeId]="typeScopeId()"
21
+ [disabled]="disabled()"
22
+ (sectionChange)="replaceSection($event)"
23
+ (validChange)="setSectionValid(section.prompt_header_id, $event)"
24
+ />
25
+ <button matButton type="button" [disabled]="disabled()" (click)="requestReset(section.prompt_header_id)">
26
+ Reset “{{ section.caption }}”
27
+ </button>
28
+ }
29
+ </div>
@@ -0,0 +1,77 @@
1
+ import { ChangeDetectionStrategy, Component, effect, input, output, signal } from '@angular/core';
2
+ import { FormControl, ReactiveFormsModule } from '@angular/forms';
3
+ import { MatButtonModule } from '@angular/material/button';
4
+ import { MatCheckboxModule } from '@angular/material/checkbox';
5
+ import { MatFormFieldModule } from '@angular/material/form-field';
6
+ import { MatSelectModule } from '@angular/material/select';
7
+ import { AgentLanguageDraft, AgentPromptSection, AgentResetScope } from '../model/agent_studio';
8
+ import { PromptSectionEditorComponent } from './prompt_section_editor';
9
+
10
+ /** What to reset; the page adds the agent name and both expected revisions. */
11
+ export interface PromptResetTarget {
12
+ scope: AgentResetScope;
13
+ /** 0 = every section. */
14
+ prompt_header_id: number;
15
+ /** '' = every language. */
16
+ language_code: string;
17
+ }
18
+
19
+ /**
20
+ * The prompt sections of one language. Emits the whole language on every edit so a save always
21
+ * carries every layer back. Selector: <sail-prompt-language-editor>.
22
+ */
23
+ @Component({
24
+ selector: 'sail-prompt-language-editor',
25
+ changeDetection: ChangeDetectionStrategy.OnPush,
26
+ imports: [ReactiveFormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatSelectModule, PromptSectionEditorComponent],
27
+ templateUrl: './prompt_language_editor.html',
28
+ })
29
+ export class PromptLanguageEditorComponent {
30
+ readonly language = input.required<AgentLanguageDraft>();
31
+ readonly agentScopeId = input('');
32
+ readonly typeScopeId = input('');
33
+ readonly disabled = input(false);
34
+
35
+ readonly languageChange = output<AgentLanguageDraft>();
36
+ readonly validChange = output<boolean>();
37
+ readonly resetRequested = output<PromptResetTarget>();
38
+
39
+ protected readonly resetScope = new FormControl<AgentResetScope>('agent_override', { nonNullable: true });
40
+ protected readonly everyLanguage = new FormControl(false, { nonNullable: true });
41
+ private readonly invalidSections = signal<ReadonlySet<number>>(new Set());
42
+
43
+ constructor() {
44
+ effect(() => {
45
+ for (const control of [this.resetScope, this.everyLanguage]) {
46
+ if (this.disabled()) control.disable({ emitEvent: false });
47
+ else control.enable({ emitEvent: false });
48
+ }
49
+ });
50
+ }
51
+
52
+ protected replaceSection(section: AgentPromptSection): void {
53
+ const current = this.language();
54
+ this.languageChange.emit({
55
+ ...current,
56
+ prompt_sections: current.prompt_sections.map((existing) =>
57
+ existing.prompt_header_id === section.prompt_header_id ? section : existing,
58
+ ),
59
+ });
60
+ }
61
+
62
+ protected setSectionValid(promptHeaderId: number, valid: boolean): void {
63
+ const invalid = new Set(this.invalidSections());
64
+ if (valid) invalid.delete(promptHeaderId);
65
+ else invalid.add(promptHeaderId);
66
+ this.invalidSections.set(invalid);
67
+ this.validChange.emit(invalid.size === 0);
68
+ }
69
+
70
+ protected requestReset(promptHeaderId: number): void {
71
+ this.resetRequested.emit({
72
+ scope: this.resetScope.value,
73
+ prompt_header_id: promptHeaderId,
74
+ language_code: this.everyLanguage.value ? '' : this.language().language_code,
75
+ });
76
+ }
77
+ }
@@ -0,0 +1,93 @@
1
+ <section class="prompt-section" [attr.aria-label]="section().caption">
2
+ <h3 class="prompt-section__caption">{{ section().caption }}</h3>
3
+ @if (section().description) {
4
+ <p class="prompt-section__description">{{ section().description }}</p>
5
+ }
6
+
7
+ @if (sealedBy(); as sealing) {
8
+ <p class="prompt-section__sealed" role="status">
9
+ <mat-icon aria-hidden="true">lock</mat-icon>
10
+ Sealed by {{ sealing.scope_kind || slotOf(sealing) }} scope {{ sealing.scope_id }}. Narrower layers cannot change this section.
11
+ </p>
12
+ }
13
+ @if (sealConflict()) {
14
+ <p class="prompt-section__conflict" role="alert">
15
+ A sealed type layer cannot be saved while the agent layer exists. Remove the agent layer or unseal the type layer.
16
+ </p>
17
+ }
18
+
19
+ <ol class="prompt-section__layers">
20
+ @for (row of rows(); track $index) {
21
+ <li
22
+ class="prompt-layer"
23
+ [class.prompt-layer--readonly]="!row.form"
24
+ [class.prompt-layer--sealed]="row.layer.sealed"
25
+ [class.prompt-layer--under-seal]="isUnderSeal($index)"
26
+ >
27
+ <div class="prompt-layer__head">
28
+ <span class="prompt-layer__scope">{{ row.layer.scope_kind || slotOf(row.layer) }}</span>
29
+ <span class="prompt-layer__scope-id">{{ row.layer.scope_id }}</span>
30
+ @if (row.layer.sealed) {
31
+ <mat-icon class="prompt-layer__seal" aria-label="Sealed" role="img">lock</mat-icon>
32
+ }
33
+ </div>
34
+
35
+ @if (row.form; as form) {
36
+ <div class="prompt-layer__form" [formGroup]="form">
37
+ <mat-form-field>
38
+ <mat-label>Merge mode</mat-label>
39
+ <mat-select formControlName="merge_mode">
40
+ <mat-option value="append">Append</mat-option>
41
+ <mat-option value="replace">Replace</mat-option>
42
+ </mat-select>
43
+ </mat-form-field>
44
+ <mat-form-field>
45
+ <mat-label>Instruction</mat-label>
46
+ <textarea matInput formControlName="instruction" rows="6"></textarea>
47
+ </mat-form-field>
48
+ <mat-form-field>
49
+ <mat-label>Output</mat-label>
50
+ <textarea matInput formControlName="output" rows="3"></textarea>
51
+ </mat-form-field>
52
+ <mat-checkbox formControlName="sealed">Seal this section for narrower scopes</mat-checkbox>
53
+
54
+ @if (form.hasError('instructionRequired')) {
55
+ <p class="prompt-layer__error" role="alert">Replace needs an instruction.</p>
56
+ }
57
+ @if (form.hasError('contentRequired')) {
58
+ <p class="prompt-layer__error" role="alert">Enter an instruction or an output.</p>
59
+ }
60
+ <button matButton type="button" [disabled]="disabled()" (click)="removeLayer($index)">
61
+ Remove layer
62
+ </button>
63
+ </div>
64
+ } @else {
65
+ <dl class="prompt-layer__readonly">
66
+ <dt>Merge mode</dt>
67
+ <dd>{{ row.layer.merge_mode }}</dd>
68
+ <dt>Instruction</dt>
69
+ <dd class="prompt-layer__text">{{ row.layer.instruction }}</dd>
70
+ <dt>Output</dt>
71
+ <dd class="prompt-layer__text">{{ row.layer.output }}</dd>
72
+ </dl>
73
+ }
74
+ </li>
75
+ }
76
+ </ol>
77
+
78
+ <div class="prompt-section__actions">
79
+ @if (canAddType()) {
80
+ <button matButton type="button" (click)="addLayer('type')">Add type-default layer</button>
81
+ }
82
+ @if (canAddAgent()) {
83
+ <button matButton type="button" (click)="addLayer('agent')">Add agent layer</button>
84
+ }
85
+ </div>
86
+
87
+ <dl class="prompt-section__effective">
88
+ <dt>Effective instruction</dt>
89
+ <dd class="prompt-layer__text">{{ section().effective_text }}</dd>
90
+ <dt>Effective output</dt>
91
+ <dd class="prompt-layer__text">{{ section().effective_output }}</dd>
92
+ </dl>
93
+ </section>
@@ -0,0 +1,170 @@
1
+ import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal, untracked } from '@angular/core';
2
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
3
+ import { FormArray, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors } from '@angular/forms';
4
+ import { MatButtonModule } from '@angular/material/button';
5
+ import { MatCheckboxModule } from '@angular/material/checkbox';
6
+ import { MatFormFieldModule } from '@angular/material/form-field';
7
+ import { MatIconModule } from '@angular/material/icon';
8
+ import { MatInputModule } from '@angular/material/input';
9
+ import { MatSelectModule } from '@angular/material/select';
10
+ import { AgentMergeMode, AgentPromptLayer, AgentPromptSection } from '../model/agent_studio';
11
+
12
+ type LayerForm = FormGroup<{
13
+ instruction: FormControl<string>;
14
+ output: FormControl<string>;
15
+ merge_mode: FormControl<AgentMergeMode>;
16
+ sealed: FormControl<boolean>;
17
+ }>;
18
+
19
+ interface LayerRow {
20
+ layer: AgentPromptLayer;
21
+ form: LayerForm | null;
22
+ }
23
+
24
+ export type EditableLayerSlot = 'type' | 'agent';
25
+
26
+ function layerContentRequired(group: LayerForm): ValidationErrors | null {
27
+ const { instruction, output, merge_mode } = group.getRawValue();
28
+ if (merge_mode === 'replace') return instruction.trim() ? null : { instructionRequired: true };
29
+ return instruction.trim() || output.trim() ? null : { contentRequired: true };
30
+ }
31
+
32
+ /**
33
+ * Editor for one prompt section's layers. Layers keep the server's order; the effective text is
34
+ * shown as received and never merged here. Ships no CSS — style the `.prompt-section*` and
35
+ * `.prompt-layer*` hooks. Selector: <sail-prompt-section-editor>.
36
+ */
37
+ @Component({
38
+ selector: 'sail-prompt-section-editor',
39
+ changeDetection: ChangeDetectionStrategy.OnPush,
40
+ imports: [ReactiveFormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule],
41
+ templateUrl: './prompt_section_editor.html',
42
+ })
43
+ export class PromptSectionEditorComponent {
44
+ readonly section = input.required<AgentPromptSection>();
45
+ readonly agentScopeId = input('');
46
+ readonly typeScopeId = input('');
47
+ readonly disabled = input(false);
48
+
49
+ readonly sectionChange = output<AgentPromptSection>();
50
+ readonly validChange = output<boolean>();
51
+
52
+ protected readonly forms = new FormArray<LayerForm>([]);
53
+ protected readonly rows = signal<LayerRow[]>([]);
54
+ private emitted?: AgentPromptSection;
55
+
56
+ protected readonly sealedAt = computed(() => this.rows().findIndex((row) => row.layer.sealed));
57
+ protected readonly sealedBy = computed(() => this.rows()[this.sealedAt()]?.layer);
58
+
59
+ /** The server refuses a save that keeps an agent layer under a sealed type layer. */
60
+ protected readonly sealConflict = computed(() => {
61
+ const rows = this.rows();
62
+ const typeSealed = rows.some((row) => row.form && row.layer.sealed && this.slotOf(row.layer) === 'type');
63
+ return typeSealed && rows.some((row) => row.form && this.slotOf(row.layer) === 'agent');
64
+ });
65
+
66
+ protected readonly canAddType = computed(() => this.canAdd(this.typeScopeId()));
67
+ protected readonly canAddAgent = computed(() => this.canAdd(this.agentScopeId()));
68
+
69
+ constructor() {
70
+ effect(() => {
71
+ const section = this.section();
72
+ if (section === this.emitted) return;
73
+ untracked(() => this.rebuild(section.layers));
74
+ });
75
+ effect(() => {
76
+ const locked = this.disabled();
77
+ const sealedAt = this.sealedAt();
78
+ untracked(() => this.applyLocks(locked, sealedAt));
79
+ });
80
+ this.forms.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => this.sync(true));
81
+ }
82
+
83
+ protected slotOf(layer: AgentPromptLayer): EditableLayerSlot | undefined {
84
+ if (layer.scope_id && layer.scope_id === this.typeScopeId()) return 'type';
85
+ if (layer.scope_id && layer.scope_id === this.agentScopeId()) return 'agent';
86
+ return undefined;
87
+ }
88
+
89
+ protected isUnderSeal(index: number): boolean {
90
+ const sealedAt = this.sealedAt();
91
+ return sealedAt >= 0 && index > sealedAt;
92
+ }
93
+
94
+ protected addLayer(slot: EditableLayerSlot): void {
95
+ const layer: AgentPromptLayer = {
96
+ scope_id: slot === 'type' ? this.typeScopeId() : this.agentScopeId(),
97
+ scope_kind: '',
98
+ merge_mode: 'append',
99
+ sealed: false,
100
+ editable: true,
101
+ instruction: '',
102
+ output: '',
103
+ };
104
+ const layers = this.rows().map((row) => row.layer);
105
+ // Type before agent, both after every read-only layer.
106
+ const agentAt = layers.findIndex((existing) => this.slotOf(existing) === 'agent');
107
+ const at = slot === 'type' && agentAt >= 0 ? agentAt : layers.length;
108
+ layers.splice(at, 0, layer);
109
+ this.rebuild(layers);
110
+ this.emit();
111
+ }
112
+
113
+ /** Removing an editable layer ends its binding on the next save. */
114
+ protected removeLayer(index: number): void {
115
+ this.rebuild(this.rows().map((row) => row.layer).filter((_, at) => at !== index));
116
+ this.emit();
117
+ }
118
+
119
+ private canAdd(scopeId: string): boolean {
120
+ if (!scopeId || this.disabled() || this.sealedAt() >= 0) return false;
121
+ return !this.rows().some((row) => row.layer.scope_id === scopeId);
122
+ }
123
+
124
+ private rebuild(layers: AgentPromptLayer[]): void {
125
+ this.forms.clear({ emitEvent: false });
126
+ const rows = layers.map((layer) => ({ layer, form: layer.editable ? this.formFor(layer) : null }));
127
+ for (const row of rows) if (row.form) this.forms.push(row.form, { emitEvent: false });
128
+ this.rows.set(rows);
129
+ this.sync(false);
130
+ }
131
+
132
+ private formFor(layer: AgentPromptLayer): LayerForm {
133
+ return new FormGroup(
134
+ {
135
+ instruction: new FormControl(layer.instruction, { nonNullable: true }),
136
+ output: new FormControl(layer.output, { nonNullable: true }),
137
+ merge_mode: new FormControl<AgentMergeMode>(layer.merge_mode, { nonNullable: true }),
138
+ sealed: new FormControl(layer.sealed, { nonNullable: true }),
139
+ },
140
+ { validators: (group) => layerContentRequired(group as LayerForm) },
141
+ );
142
+ }
143
+
144
+ private sync(announce: boolean): void {
145
+ this.rows.update((rows) =>
146
+ rows.map((row) => (row.form ? { ...row, layer: { ...row.layer, ...row.form.getRawValue() } } : row)),
147
+ );
148
+ if (announce) this.emit();
149
+ }
150
+
151
+ private applyLocks(locked: boolean, sealedAt: number): void {
152
+ this.rows().forEach((row, index) => {
153
+ if (!row.form) return;
154
+ const lock = locked || (sealedAt >= 0 && index > sealedAt);
155
+ if (lock && row.form.enabled) row.form.disable({ emitEvent: false });
156
+ if (!lock && row.form.disabled) row.form.enable({ emitEvent: false });
157
+ });
158
+ this.validChange.emit(this.isValid());
159
+ }
160
+
161
+ private isValid(): boolean {
162
+ return !this.sealConflict() && this.rows().every((row) => !row.form || !row.form.invalid);
163
+ }
164
+
165
+ private emit(): void {
166
+ this.emitted = { ...this.section(), layers: this.rows().map((row) => row.layer) };
167
+ this.sectionChange.emit(this.emitted);
168
+ this.validChange.emit(this.isValid());
169
+ }
170
+ }
@@ -0,0 +1,29 @@
1
+ @for (language of languages(); track language.language_code) {
2
+ <section class="release-language" [attr.aria-label]="language.language_code">
3
+ <h3 class="release-language__code">{{ language.language_code }}</h3>
4
+ @for (section of language.sections; track section.prompt_header_id) {
5
+ <article class="release-section">
6
+ <h4 class="release-section__caption">{{ section.caption }}</h4>
7
+ <p class="release-section__source">
8
+ Decided by {{ section.source.scope_kind }} scope {{ section.source.scope_id }}
9
+ ({{ section.source.merge_mode }})
10
+ @if (section.source.sealed) {
11
+ <span class="release-section__sealed">
12
+ <mat-icon aria-hidden="true">lock</mat-icon>
13
+ sealed
14
+ </span>
15
+ }
16
+ </p>
17
+ @if (section.description) {
18
+ <p class="release-section__description">{{ section.description }}</p>
19
+ }
20
+ <dl class="release-section__copy">
21
+ <dt>Instruction</dt>
22
+ <dd class="release-section__text">{{ section.instruction }}</dd>
23
+ <dt>Output</dt>
24
+ <dd class="release-section__text">{{ section.output }}</dd>
25
+ </dl>
26
+ </article>
27
+ }
28
+ </section>
29
+ }
@@ -0,0 +1,32 @@
1
+ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
2
+ import { MatIconModule } from '@angular/material/icon';
3
+ import { AgentReleaseSection } from '../model/agent_studio';
4
+
5
+ interface ReleaseLanguageGroup {
6
+ language_code: string;
7
+ sections: AgentReleaseSection[];
8
+ }
9
+
10
+ /**
11
+ * Frozen prompt sections of one release, each beside the layer that decided it. Read-only:
12
+ * provenance never changes after publication. Selector: <sail-release-sections>.
13
+ */
14
+ @Component({
15
+ selector: 'sail-release-sections',
16
+ changeDetection: ChangeDetectionStrategy.OnPush,
17
+ imports: [MatIconModule],
18
+ templateUrl: './release_sections.html',
19
+ })
20
+ export class ReleaseSectionsComponent {
21
+ readonly sections = input.required<AgentReleaseSection[]>();
22
+
23
+ protected readonly languages = computed<ReleaseLanguageGroup[]>(() => {
24
+ const groups = new Map<string, AgentReleaseSection[]>();
25
+ for (const section of this.sections()) {
26
+ const group = groups.get(section.language_code);
27
+ if (group) group.push(section);
28
+ else groups.set(section.language_code, [section]);
29
+ }
30
+ return [...groups].map(([language_code, sections]) => ({ language_code, sections }));
31
+ });
32
+ }
@@ -0,0 +1,101 @@
1
+ <ol class="turn-events" aria-live="polite">
2
+ @for (item of items(); track $index) {
3
+ @switch (item.kind) {
4
+ @case ('text') {
5
+ <li class="turn-text">{{ item.text }}</li>
6
+ }
7
+ @case ('tool') {
8
+ <li class="turn-tool" [class.turn-tool--error]="item.errorClass !== undefined">
9
+ <div class="turn-tool__head">
10
+ <mat-icon aria-hidden="true">build</mat-icon>
11
+ <span class="turn-tool__id">{{ item.tool_id }}</span>
12
+ <span class="turn-tool__version">{{ item.tool_version }}</span>
13
+ </div>
14
+
15
+ @if (item.approval === 'pending') {
16
+ <p class="turn-tool__approval turn-tool__approval--pending" role="status">Waiting for approval</p>
17
+ } @else if (item.approval === 'resolved') {
18
+ <p class="turn-tool__approval" role="status">Approval: {{ item.decision }}</p>
19
+ }
20
+
21
+ @if (item.arguments !== undefined) {
22
+ <pre class="turn-tool__arguments">{{ item.arguments | json }}</pre>
23
+ }
24
+
25
+ @if (item.errorClass !== undefined) {
26
+ <p class="turn-tool__error" role="alert">Tool call failed: {{ item.errorClass }}</p>
27
+ } @else if (item.result) {
28
+ <pre class="turn-tool__output">{{ item.result.output | json }}</pre>
29
+ }
30
+
31
+ @if (item.effect; as effect) {
32
+ <div
33
+ class="turn-effect"
34
+ [class.turn-effect--satisfied]="effect.observation.status === 'satisfied'"
35
+ [class.turn-effect--violated]="effect.observation.status === 'violated'"
36
+ [class.turn-effect--unknown]="effect.observation.status === 'unknown'"
37
+ >
38
+ @switch (effect.observation.status) {
39
+ @case ('satisfied') {
40
+ <span class="turn-effect__status">Effect confirmed</span>
41
+ }
42
+ @case ('violated') {
43
+ <span class="turn-effect__status" role="alert">Effect not confirmed</span>
44
+ }
45
+ @case ('unknown') {
46
+ <span class="turn-effect__status">Effect could not be verified</span>
47
+ }
48
+ }
49
+ @if (effect.observation.reconciled) {
50
+ <span class="turn-effect__reconciled">Already in place — nothing was sent again</span>
51
+ }
52
+ @if (effect.observation.reason) {
53
+ <p class="turn-effect__reason">{{ effect.observation.reason }}</p>
54
+ }
55
+ @if (effect.observation.evidence?.length) {
56
+ <ul class="turn-effect__evidence">
57
+ @for (ref of effect.observation.evidence; track ref.URI) {
58
+ <li>
59
+ <span class="turn-ref__uri">{{ ref.URI }}</span>
60
+ <span class="turn-ref__digest">{{ ref.Digest }}</span>
61
+ </li>
62
+ }
63
+ </ul>
64
+ }
65
+ </div>
66
+ }
67
+ </li>
68
+ }
69
+ @case ('evidence') {
70
+ <li class="turn-evidence">
71
+ <ul>
72
+ @for (ref of item.refs; track ref.id) {
73
+ <li>
74
+ <span class="turn-ref__source">{{ ref.source }}</span>
75
+ <span class="turn-ref__uri">{{ ref.uri || ref.object?.URI }}</span>
76
+ @if (ref.object?.Digest) {
77
+ <span class="turn-ref__digest">{{ ref.object?.Digest }}</span>
78
+ }
79
+ </li>
80
+ }
81
+ </ul>
82
+ </li>
83
+ }
84
+ @case ('progress') {
85
+ <li class="turn-progress">
86
+ <span class="turn-progress__stage">{{ item.progress.stage }}</span>
87
+ @if (item.progress.message) {
88
+ <span class="turn-progress__message">{{ item.progress.message }}</span>
89
+ }
90
+ </li>
91
+ }
92
+ @case ('extension') {
93
+ @if (extensionTemplate(); as template) {
94
+ <li class="turn-extension">
95
+ <ng-container [ngTemplateOutlet]="template" [ngTemplateOutletContext]="{ $implicit: item.extension }" />
96
+ </li>
97
+ }
98
+ }
99
+ }
100
+ }
101
+ </ol>