@crombolo/backoffice-host 0.0.0-stage → 0.9.1

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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/assets/login.js +352 -0
  4. package/package.json +35 -4
  5. package/plugin/assistant/client.js +53 -0
  6. package/plugin/assistant/drawer.js +865 -0
  7. package/plugin/assistant/umbraco-package.json +16 -0
  8. package/plugin/branding/backdrop.svg +1 -0
  9. package/plugin/branding/branding.js +100 -0
  10. package/plugin/branding/bundle-builder-labels.js +63 -0
  11. package/plugin/branding/client-credentials.js +49 -0
  12. package/plugin/branding/components-tree.js +40 -0
  13. package/plugin/branding/content-type-labels.js +27 -0
  14. package/plugin/branding/favicon.svg +1 -0
  15. package/plugin/branding/help-menu.js +46 -0
  16. package/plugin/branding/icon-mark.js +15 -0
  17. package/plugin/branding/icon-registry.js +40 -0
  18. package/plugin/branding/localization-en.js +115 -0
  19. package/plugin/branding/log-viewer-menu.js +70 -0
  20. package/plugin/branding/logo-header.svg +1 -0
  21. package/plugin/branding/logo-surface.svg +1 -0
  22. package/plugin/branding/query-builder.js +28 -0
  23. package/plugin/branding/sign-in.js +80 -0
  24. package/plugin/branding/sysinfo.js +89 -0
  25. package/plugin/branding/theme.css +102 -0
  26. package/plugin/bundles-client.js +34 -0
  27. package/plugin/bundles-created-overview.js +150 -0
  28. package/plugin/bundles-created.js +70 -0
  29. package/plugin/bundles-installed.js +147 -0
  30. package/plugin/bundles-marketplace.js +199 -0
  31. package/plugin/changes-dashboard.js +132 -0
  32. package/plugin/element-type-hint.js +58 -0
  33. package/plugin/form-picker.js +116 -0
  34. package/plugin/forms-client.js +52 -0
  35. package/plugin/forms-entries.js +244 -0
  36. package/plugin/forms-overview.js +380 -0
  37. package/plugin/localizations/ar.js +97 -0
  38. package/plugin/localizations/bs.js +86 -0
  39. package/plugin/localizations/cs.js +73 -0
  40. package/plugin/localizations/cy.js +92 -0
  41. package/plugin/localizations/da.js +101 -0
  42. package/plugin/localizations/de.js +79 -0
  43. package/plugin/localizations/en.js +71 -0
  44. package/plugin/localizations/es.js +52 -0
  45. package/plugin/localizations/fr.js +78 -0
  46. package/plugin/localizations/he.js +24 -0
  47. package/plugin/localizations/hr.js +89 -0
  48. package/plugin/localizations/it.js +86 -0
  49. package/plugin/localizations/ja.js +43 -0
  50. package/plugin/localizations/ko.js +24 -0
  51. package/plugin/localizations/nb.js +44 -0
  52. package/plugin/localizations/nl.js +85 -0
  53. package/plugin/localizations/pl.js +44 -0
  54. package/plugin/localizations/pt-br.js +25 -0
  55. package/plugin/localizations/pt.js +100 -0
  56. package/plugin/localizations/ro.js +9 -0
  57. package/plugin/localizations/ru.js +46 -0
  58. package/plugin/localizations/sv.js +46 -0
  59. package/plugin/localizations/tr.js +80 -0
  60. package/plugin/localizations/uk.js +46 -0
  61. package/plugin/localizations/umbraco-package.json +279 -0
  62. package/plugin/localizations/vi.js +104 -0
  63. package/plugin/localizations/zh-tw.js +38 -0
  64. package/plugin/localizations/zh.js +38 -0
  65. package/plugin/read-only-banner.js +48 -0
  66. package/plugin/report-client.js +13 -0
  67. package/plugin/seo/client.js +35 -0
  68. package/plugin/seo/dashboard.js +242 -0
  69. package/plugin/seo/document-view.js +161 -0
  70. package/plugin/seo/type-hint.js +45 -0
  71. package/plugin/seo/umbraco-package.json +61 -0
  72. package/plugin/settings-dashboard.js +123 -0
  73. package/plugin/tsx-editors.js +337 -0
  74. package/plugin/tsx-snippets.js +63 -0
  75. package/plugin/umbraco-package.json +298 -0
  76. package/plugin/welcome-dashboard.js +106 -0
  77. package/src/branding.ts +59 -0
  78. package/src/extensions.ts +228 -0
  79. package/src/graphics.ts +87 -0
  80. package/src/index.ts +7 -0
  81. package/src/login-assets.ts +18 -0
  82. package/src/manifests.ts +154 -0
  83. package/src/paths.ts +117 -0
  84. package/src/shell.ts +139 -0
  85. package/src/static.ts +107 -0
  86. package/src/text-imports.d.ts +23 -0
  87. package/README.md +0 -3
@@ -0,0 +1,865 @@
1
+ /**
2
+ * The assistant's review surface: a near-fullscreen workspace with the proposals
3
+ * on the left and the selected one open on the right, editable.
4
+ *
5
+ * It is laid out like an editor because that is the job. A proposal is a change
6
+ * to code or to content, approving it is the only thing that reaches the CMS, and
7
+ * a one-line summary is not enough to approve on. So: pick a change, read it,
8
+ * change it if it is nearly right, then approve it.
9
+ *
10
+ * What the right pane shows follows what the change actually is, rather than one
11
+ * shape for everything: a template is TSX in a code editor, a page is its property
12
+ * values as markup, a type is the prepared request as JSON.
13
+ *
14
+ * Registered as a `headerApp`, whose manifest the server omits entirely when the
15
+ * assistant is not configured.
16
+ */
17
+ import { css, html, LitElement, nothing } from '@umbraco-cms/backoffice/external/lit'
18
+ import { approve, ask, change, changesets, diff, discard, edit, status } from './client.js'
19
+
20
+ /**
21
+ * The language each kind of change is edited in. `tsx` is crombolo's own, added
22
+ * to the code editor by the `tsx-editors` entry point; plain `typescript` would
23
+ * read a view's markup as a stream of syntax errors.
24
+ */
25
+ const LANGUAGE = {
26
+ template: 'tsx',
27
+ 'template-create': 'tsx',
28
+ }
29
+
30
+ const KIND_LABEL = {
31
+ document: 'Page',
32
+ 'document-create': 'New page',
33
+ 'document-type': 'Content type',
34
+ 'document-type-create': 'New content type',
35
+ 'data-type': 'Data type',
36
+ 'data-type-create': 'New data type',
37
+ template: 'Template',
38
+ 'template-create': 'New template',
39
+ }
40
+
41
+ export default class CromboloAssistantDrawerElement extends LitElement {
42
+ static properties = {
43
+ _open: { state: true },
44
+ _busy: { state: true },
45
+ _turns: { state: true },
46
+ _changes: { state: true },
47
+ _selected: { state: true },
48
+ _detail: { state: true },
49
+ _review: { state: true },
50
+ _dirty: { state: true },
51
+ _status: { state: true },
52
+ _error: { state: true },
53
+ _split: { state: true },
54
+ }
55
+
56
+ constructor() {
57
+ super()
58
+ this._open = false
59
+ this._busy = false
60
+ this._turns = []
61
+ this._changes = []
62
+ this._selected = undefined
63
+ this._detail = undefined
64
+ this._review = undefined
65
+ this._dirty = false
66
+ this._status = undefined
67
+ this._error = undefined
68
+ // Percentage width of the left pane; dragged by the splitter.
69
+ this._split = 32
70
+ this._changesetKey = undefined
71
+ this._history = []
72
+ this._pending = undefined
73
+ this._pendingText = undefined
74
+ }
75
+
76
+ /**
77
+ * A field rather than a method: the window listener needs a stable bound
78
+ * reference to add and remove, and a private method cannot be reassigned.
79
+ */
80
+ #onKey = (event) => {
81
+ if (event.key === 'Escape' && this._open) this.#toggle()
82
+ }
83
+
84
+ disconnectedCallback() {
85
+ super.disconnectedCallback()
86
+ window.removeEventListener('keydown', this.#onKey)
87
+ }
88
+
89
+ async #toggle() {
90
+ this._open = !this._open
91
+ if (this._open) {
92
+ window.addEventListener('keydown', this.#onKey)
93
+ await Promise.all([this.#refresh(), this.#readStatus()])
94
+ } else {
95
+ window.removeEventListener('keydown', this.#onKey)
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Read on opening, so a signed-out AWS session is visible before anything is
101
+ * typed rather than as a failed message afterwards.
102
+ */
103
+ async #readStatus(refresh = false) {
104
+ try {
105
+ this._status = await status(refresh)
106
+ } catch (error) {
107
+ this._status = { ok: false, detail: error.message }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Does not clear `_error`: a refused approval reports through it and then calls
113
+ * this, so clearing here would wipe the message before it was read.
114
+ *
115
+ * Anything still awaiting review is listed however old it is, proposals raised
116
+ * over MCP in another process included. Settled items are shown only for this
117
+ * conversation, so the list does not grow for ever.
118
+ */
119
+ async #refresh() {
120
+ try {
121
+ const sets = await changesets()
122
+ this._changes = sets
123
+ .flatMap((set) =>
124
+ set.changes.map((item) => ({ ...item, origin: set.origin, changeset: set.key })),
125
+ )
126
+ .filter((item) => item.status === 'proposed' || item.changeset === this._changesetKey)
127
+ if (this._selected && !this._changes.some((item) => item.key === this._selected)) {
128
+ await this.#select(undefined)
129
+ }
130
+ } catch (error) {
131
+ this._error = error.message
132
+ }
133
+ }
134
+
135
+ async #select(key) {
136
+ this._selected = key
137
+ this._detail = undefined
138
+ this._review = undefined
139
+ this._dirty = false
140
+ this._pending = undefined
141
+ this._pendingText = undefined
142
+ if (!key) return
143
+ try {
144
+ const [detail, review] = await Promise.all([change(key), diff(key).catch(() => undefined)])
145
+ this._detail = detail
146
+ this._review = review
147
+ } catch (error) {
148
+ this._error = error.message
149
+ }
150
+ }
151
+
152
+ async #submit(event) {
153
+ event.preventDefault()
154
+ const input = this.renderRoot.querySelector('textarea.prompt')
155
+ const message = input?.value?.trim()
156
+ if (!message || this._busy) return
157
+ input.value = ''
158
+ this._turns = [...this._turns, { role: 'user', text: message }]
159
+ this._busy = true
160
+ this._error = undefined
161
+ try {
162
+ const answer = await ask(message, {
163
+ changesetKey: this._changesetKey,
164
+ history: this._history,
165
+ viewing: this.#viewing(),
166
+ })
167
+ this._changesetKey = answer.changesetKey
168
+ this._history = answer.history ?? []
169
+ this._turns = [...this._turns, { role: 'assistant', text: answer.text }]
170
+ await this.#refresh()
171
+ // Open the first thing it proposed, so the work is in front of you.
172
+ const first = this._changes.find((item) => item.status === 'proposed')
173
+ if (first && !this._selected) await this.#select(first.key)
174
+ } catch (error) {
175
+ this._error = error.message
176
+ } finally {
177
+ this._busy = false
178
+ }
179
+ }
180
+
181
+ /** A hint for the model about where the user is, taken from the address bar. */
182
+ #viewing() {
183
+ const match = /\/section\/([^/]+)/.exec(window.location.pathname)
184
+ return match ? `the ${match[1]} section (${window.location.pathname})` : undefined
185
+ }
186
+
187
+ async #settle(item, action) {
188
+ this._busy = true
189
+ this._error = undefined
190
+ try {
191
+ await (action === 'approve' ? approve(item.key) : discard(item.key))
192
+ } catch (error) {
193
+ this._error = error.message
194
+ } finally {
195
+ this._busy = false
196
+ await this.#refresh()
197
+ if (this._selected === item.key) await this.#select(item.key)
198
+ }
199
+ }
200
+
201
+ /** Saves the edited body back to the proposal, and re-reads what it now says. */
202
+ async #save(body) {
203
+ if (body === undefined) return
204
+ this._busy = true
205
+ this._error = undefined
206
+ try {
207
+ await edit(this._selected, body)
208
+ this._dirty = false
209
+ await this.#refresh()
210
+ await this.#select(this._selected)
211
+ } catch (error) {
212
+ this._error = error.message
213
+ } finally {
214
+ this._busy = false
215
+ }
216
+ }
217
+
218
+ #startDrag(event) {
219
+ event.preventDefault()
220
+ const panel = this.renderRoot.querySelector('.panes')
221
+ if (!panel) return
222
+ const move = (moved) => {
223
+ const bounds = panel.getBoundingClientRect()
224
+ const percent = ((moved.clientX - bounds.left) / bounds.width) * 100
225
+ // Neither pane is allowed to collapse to nothing.
226
+ this._split = Math.min(60, Math.max(18, percent))
227
+ }
228
+ const stop = () => {
229
+ window.removeEventListener('pointermove', move)
230
+ window.removeEventListener('pointerup', stop)
231
+ }
232
+ window.addEventListener('pointermove', move)
233
+ window.addEventListener('pointerup', stop)
234
+ }
235
+
236
+ render() {
237
+ return html`
238
+ <uui-button
239
+ class="launcher"
240
+ look="primary"
241
+ compact
242
+ label="Assistant"
243
+ title="Ask the assistant to draft content and schema changes for you to approve"
244
+ @click=${() => this.#toggle()}
245
+ >
246
+ <umb-icon name="icon-wand"></umb-icon>
247
+ </uui-button>
248
+ ${this._open ? this.#renderWorkspace() : nothing}
249
+ `
250
+ }
251
+
252
+ #renderWorkspace() {
253
+ const awaiting = this._changes.filter((item) => item.status === 'proposed')
254
+ return html`
255
+ <div class="scrim" @click=${() => this.#toggle()}></div>
256
+ <section class="workspace" role="dialog" aria-label="Assistant">
257
+ <header>
258
+ <strong>Assistant</strong>
259
+ <span class="overview">${this.#overview(awaiting)}</span>
260
+ <uui-button compact look="default" label="Close" @click=${() => this.#toggle()}>
261
+ <umb-icon name="icon-delete"></umb-icon>
262
+ </uui-button>
263
+ </header>
264
+
265
+ ${this.#renderStatus()}
266
+ ${this._error ? html`<p class="error bar">${this._error}</p>` : nothing}
267
+
268
+ <div class="panes">
269
+ <div class="left" style="width: ${this._split}%">${this.#renderList(awaiting)}</div>
270
+ <div
271
+ class="splitter"
272
+ role="separator"
273
+ aria-orientation="vertical"
274
+ @pointerdown=${(event) => this.#startDrag(event)}
275
+ ></div>
276
+ <div class="right">${this.#renderDetail()}</div>
277
+ </div>
278
+ </section>
279
+ `
280
+ }
281
+
282
+ /**
283
+ * The explanatory text: what the assistant said it was doing, and what is
284
+ * actually waiting. The prose is the useful half; the count keeps it honest.
285
+ */
286
+ #overview(awaiting) {
287
+ const said = [...this._turns].reverse().find((turn) => turn.role === 'assistant')?.text
288
+ if (awaiting.length === 0) {
289
+ return said ?? 'Nothing is waiting for you. Ask for a change and it will appear here.'
290
+ }
291
+ const kinds = new Map()
292
+ for (const item of awaiting) {
293
+ const label = KIND_LABEL[item.kind] ?? item.kind
294
+ kinds.set(label, (kinds.get(label) ?? 0) + 1)
295
+ }
296
+ const parts = [...kinds].map(([label, count]) => `${count} ${label.toLowerCase()}`)
297
+ const counted = `${awaiting.length} proposal${awaiting.length === 1 ? '' : 's'} waiting — ${parts.join(', ')}. Nothing has changed yet.`
298
+ return said ? `${said} · ${counted}` : counted
299
+ }
300
+
301
+ #renderList(awaiting) {
302
+ return html`
303
+ <div class="list">
304
+ ${
305
+ this._changes.length === 0
306
+ ? html`<p class="hint">
307
+ Proposals appear here. Approving one is the only thing that changes the CMS.
308
+ </p>`
309
+ : html`<ul>
310
+ ${this._changes.map((item) => this.#renderListItem(item))}
311
+ </ul>`
312
+ }
313
+ ${awaiting.length > 1 ? html`<p class="hint">Each is approved on its own.</p>` : nothing}
314
+ </div>
315
+
316
+ <div class="conversation">
317
+ ${this._turns.map((turn) => html`<div class="turn ${turn.role}">${turn.text}</div>`)}
318
+ ${this._busy ? html`<div class="turn assistant">Thinking…</div>` : nothing}
319
+ </div>
320
+
321
+ <form @submit=${(event) => this.#submit(event)}>
322
+ <textarea
323
+ class="prompt"
324
+ rows="4"
325
+ placeholder="Draft a landing page about…"
326
+ @keydown=${(event) => {
327
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) this.#submit(event)
328
+ }}
329
+ ></textarea>
330
+ <uui-button type="submit" look="primary" label="Send" .disabled=${this._busy}></uui-button>
331
+ </form>
332
+ `
333
+ }
334
+
335
+ #renderListItem(item) {
336
+ const blocked = item.problems?.length > 0
337
+ return html`<li>
338
+ <button
339
+ class="entry ${item.key === this._selected ? 'selected' : ''} ${item.status}"
340
+ @click=${() => this.#select(item.key)}
341
+ >
342
+ <span class="kind">${KIND_LABEL[item.kind] ?? item.kind}</span>
343
+ <span class="summary">${item.summary}</span>
344
+ <span class="tags">
345
+ ${
346
+ item.status !== 'proposed'
347
+ ? html`<uui-tag look="secondary">${item.status}</uui-tag>`
348
+ : nothing
349
+ }
350
+ ${item.origin === 'mcp' ? html`<uui-tag look="secondary">MCP</uui-tag>` : nothing}
351
+ ${blocked ? html`<uui-tag color="danger" look="secondary">blocked</uui-tag>` : nothing}
352
+ </span>
353
+ </button>
354
+ </li>`
355
+ }
356
+
357
+ #renderDetail() {
358
+ if (!this._selected) {
359
+ return html`<div class="empty">
360
+ <p>Select a proposal to read it, change it, or approve it.</p>
361
+ </div>`
362
+ }
363
+ if (!this._detail) return html`<div class="empty"><p>Loading…</p></div>`
364
+
365
+ const item = this._detail
366
+ const settled = item.status !== 'proposed'
367
+ const blocked = item.problems?.length > 0
368
+ return html`
369
+ <div class="detail">
370
+ <div class="detail-head">
371
+ <div>
372
+ <strong>${item.summary}</strong>
373
+ <p class="effect">${item.effect}</p>
374
+ </div>
375
+ <div class="actions">
376
+ ${
377
+ settled
378
+ ? html`<uui-tag look="secondary">${item.status}</uui-tag>`
379
+ : html`
380
+ <uui-button
381
+ look="primary"
382
+ color="positive"
383
+ label="Approve"
384
+ .disabled=${this._busy || blocked || this._dirty}
385
+ @click=${() => this.#settle(item, 'approve')}
386
+ ></uui-button>
387
+ <uui-button
388
+ look="secondary"
389
+ label="Discard"
390
+ .disabled=${this._busy}
391
+ @click=${() => this.#settle(item, 'discard')}
392
+ ></uui-button>
393
+ `
394
+ }
395
+ </div>
396
+ </div>
397
+
398
+ ${
399
+ blocked
400
+ ? html`<ul class="problems">
401
+ ${item.problems.map((problem) => html`<li>${problem.message}</li>`)}
402
+ </ul>`
403
+ : nothing
404
+ }
405
+ ${
406
+ this._review?.stale
407
+ ? html`<p class="error bar">
408
+ This changed after it was proposed. Ask for it again rather than approving it.
409
+ </p>`
410
+ : nothing
411
+ }
412
+ ${this._dirty ? html`<p class="hint bar">Edited — save before approving.</p>` : nothing}
413
+
414
+ ${this.#renderDescription()}
415
+ ${this.#renderEditor(item)}
416
+ </div>
417
+ `
418
+ }
419
+
420
+ /**
421
+ * What the proposal does, in words. The parts a person cannot edit — which type
422
+ * a page is, which parent it sits under — are still theirs to review, and the
423
+ * prepared request was never a readable way to show them.
424
+ */
425
+ #renderDescription() {
426
+ const lines = this._review?.lines
427
+ if (!lines?.length) return nothing
428
+ return html`<div class="described">
429
+ <ul>
430
+ ${lines.map((line) => html`<li>${line}</li>`)}
431
+ </ul>
432
+ ${
433
+ this._review?.editable
434
+ ? html`<p class="hint">You can change ${this._review.editable}; the rest is fixed.</p>`
435
+ : html`<p class="hint">Nothing here is editable \u2014 approve it or discard it.</p>`
436
+ }
437
+ </div>`
438
+ }
439
+
440
+ #renderEditor(item) {
441
+ // Settled: the description above already says what it did, and the prepared
442
+ // request is not something to read. Templates and files are still worth
443
+ // showing, because the text is the change.
444
+ if (item.status !== 'proposed') {
445
+ if (item.kind.startsWith('template')) {
446
+ return html`<pre class="readonly">${item.body?.content ?? ''}</pre>`
447
+ }
448
+ if (typeof item.body?.toml === 'string') {
449
+ return html`<pre class="readonly">${item.body.toml}</pre>`
450
+ }
451
+ return nothing
452
+ }
453
+ if (item.kind.startsWith('template')) return this.#renderTemplateEditor(item)
454
+ if (item.kind.startsWith('document-type') || item.kind.startsWith('data-type')) {
455
+ return this.#renderTomlEditor(item)
456
+ }
457
+ return this.#renderValuesEditor(item)
458
+ }
459
+
460
+ /** A template is TSX, so it gets a code editor and the file's own language. */
461
+ #renderTemplateEditor(item) {
462
+ const source = typeof item.body?.content === 'string' ? item.body.content : ''
463
+ return html`
464
+ <div class="editor">
465
+ <umb-code-editor
466
+ language=${LANGUAGE[item.kind] ?? 'tsx'}
467
+ .code=${source}
468
+ @input=${(event) => {
469
+ // Only the field this kind is edited through; the server refuses the rest.
470
+ this._pending = { content: event.target.code ?? event.target.value }
471
+ this._dirty = true
472
+ }}
473
+ ></umb-code-editor>
474
+ </div>
475
+ ${this.#renderSave(() => this._pending)}
476
+ `
477
+ }
478
+
479
+ /** A page is its property values; each one is markup, so each gets an editor. */
480
+ #renderValuesEditor(item) {
481
+ const values = Array.isArray(item.body?.values) ? item.body.values : []
482
+ if (values.length === 0) {
483
+ return html`<p class="hint pad">This proposal sets no property values.</p>`
484
+ }
485
+ return html`
486
+ <div class="values">
487
+ ${values.map(
488
+ (value, index) => html`<label class="value">
489
+ <span>
490
+ ${value.alias}${value.culture ? html` <small>${value.culture}</small>` : nothing}
491
+ </span>
492
+ <textarea
493
+ rows="6"
494
+ .value=${
495
+ typeof value.value === 'string' ? value.value : JSON.stringify(value.value ?? '')
496
+ }
497
+ @input=${(event) => this.#editValue(item, index, event.target.value)}
498
+ ></textarea>
499
+ </label>`,
500
+ )}
501
+ </div>
502
+ ${this.#renderSave(() => this._pending)}
503
+ `
504
+ }
505
+
506
+ #editValue(item, index, next) {
507
+ const values = (this._pending?.values ?? item.body.values).map((value, at) =>
508
+ at === index ? { ...value, value: next } : value,
509
+ )
510
+ this._pending = { values }
511
+ this._dirty = true
512
+ }
513
+
514
+ /**
515
+ * A type is a TOML file, and that file is what gets committed — so the file is
516
+ * what is shown and edited, aliases and all, rather than a request body of keys.
517
+ *
518
+ * `ini` rather than a TOML mode: Umbraco's editor has no TOML language, and
519
+ * ini's highlighting of `[table]` headers and `key = value` is right for it.
520
+ */
521
+ #renderTomlEditor(item) {
522
+ // A proposal raised before types were files has a request body and no TOML.
523
+ // Showing it as an empty editor would invite someone to approve nothing, so it
524
+ // is shown as what it is and says to ask again.
525
+ if (typeof item.body?.toml !== 'string') {
526
+ return html`
527
+ <p class="hint pad">
528
+ This was proposed before types were files, so there is no file to show or edit.
529
+ Discard it and ask again.
530
+ </p>
531
+ `
532
+ }
533
+ const toml = item.body.toml
534
+ return html`
535
+ <p class="hint pad">${item.body?.file ?? 'schema file'}</p>
536
+ <div class="editor">
537
+ <umb-code-editor
538
+ language="ini"
539
+ .code=${toml}
540
+ @input=${(event) => {
541
+ this._pending = { toml: event.target.code ?? event.target.value }
542
+ this._dirty = true
543
+ }}
544
+ ></umb-code-editor>
545
+ </div>
546
+ ${this.#renderSave(() => this._pending)}
547
+ `
548
+ }
549
+
550
+ #renderSave(collect) {
551
+ return html`<div class="save">
552
+ <uui-button
553
+ look="secondary"
554
+ label="Save changes"
555
+ .disabled=${this._busy || !this._dirty}
556
+ @click=${() => this.#save(collect())}
557
+ ></uui-button>
558
+ <span class="hint">Saving re-checks it. Approving is still separate.</span>
559
+ </div>`
560
+ }
561
+
562
+ /** Only says anything when something is wrong, or when a session is running out. */
563
+ #renderStatus() {
564
+ const state = this._status
565
+ if (!state || (state.ok && !state.expires)) return nothing
566
+ if (state.ok) {
567
+ return html`<p class="hint bar">
568
+ ${state.detail}. Session expires ${new Date(state.expires).toLocaleTimeString()}.
569
+ </p>`
570
+ }
571
+ return html`<div class="unavailable bar">
572
+ <strong>The assistant cannot reach its model.</strong>
573
+ <p>${state.detail}</p>
574
+ ${state.remedy ? html`<p class="remedy">${state.remedy}</p>` : nothing}
575
+ ${
576
+ // Asking again is a real call to the model, so the server allows it only
577
+ // to whoever could act on the answer. No button for anyone else: one that
578
+ // quietly changes nothing is worse than none.
579
+ state.canRefresh === false
580
+ ? nothing
581
+ : html`<uui-button
582
+ look="secondary"
583
+ label="Re-check"
584
+ .disabled=${this._busy}
585
+ @click=${() => this.#readStatus(true)}
586
+ ></uui-button>`
587
+ }
588
+ </div>`
589
+ }
590
+
591
+ static styles = css`
592
+ :host {
593
+ display: flex;
594
+ align-items: center;
595
+ }
596
+ /**
597
+ * As Umbraco's own header apps are built (UmbHeaderAppButtonElement): an
598
+ * \`umb-icon\`, because that is what resolves a registry icon name — \`uui-icon\`
599
+ * renders an empty box for one — and \`look="primary"\` with the background
600
+ * overridden, which is what gives contrast against the dark header.
601
+ */
602
+ .launcher {
603
+ font-size: 18px;
604
+ --uui-button-background-color: transparent;
605
+ --uui-button-background-color-hover: var(--uui-color-emphasis);
606
+ }
607
+ .scrim {
608
+ position: fixed;
609
+ inset: 0;
610
+ background: rgba(0, 0, 0, 0.45);
611
+ z-index: 9998;
612
+ }
613
+ .workspace {
614
+ position: fixed;
615
+ inset: 4vh 3vw;
616
+ display: flex;
617
+ flex-direction: column;
618
+ background: var(--uui-color-surface, #fff);
619
+ border-radius: var(--uui-border-radius, 3px);
620
+ box-shadow: var(--uui-shadow-depth-5, 0 20px 60px rgba(0, 0, 0, 0.35));
621
+ z-index: 9999;
622
+ overflow: hidden;
623
+ }
624
+ header {
625
+ display: flex;
626
+ align-items: center;
627
+ gap: var(--uui-size-space-4, 12px);
628
+ padding: var(--uui-size-space-3, 9px) var(--uui-size-space-4, 12px);
629
+ border-bottom: 1px solid var(--uui-color-border, #d8d7d9);
630
+ }
631
+ header .overview {
632
+ flex: 1;
633
+ font-size: var(--uui-type-small-size, 12px);
634
+ color: var(--uui-color-text-alt, #515054);
635
+ }
636
+ .bar {
637
+ margin: 0;
638
+ padding: var(--uui-size-space-2, 6px) var(--uui-size-space-4, 12px);
639
+ font-size: var(--uui-type-small-size, 12px);
640
+ border-bottom: 1px solid var(--uui-color-border, #d8d7d9);
641
+ }
642
+ .pad {
643
+ padding: var(--uui-size-space-4, 12px);
644
+ }
645
+ .panes {
646
+ flex: 1;
647
+ display: flex;
648
+ min-height: 0;
649
+ }
650
+ .left {
651
+ display: flex;
652
+ flex-direction: column;
653
+ min-width: 0;
654
+ border-right: 1px solid var(--uui-color-border, #d8d7d9);
655
+ }
656
+ .splitter {
657
+ width: 6px;
658
+ cursor: col-resize;
659
+ background: var(--uui-color-divider, #f3f3f5);
660
+ flex: none;
661
+ }
662
+ .splitter:hover {
663
+ background: var(--uui-color-emphasis, #d8d7d9);
664
+ }
665
+ .right {
666
+ flex: 1;
667
+ min-width: 0;
668
+ display: flex;
669
+ overflow: auto;
670
+ }
671
+ .list {
672
+ flex: 1;
673
+ overflow: auto;
674
+ padding: var(--uui-size-space-3, 9px);
675
+ min-height: 0;
676
+ }
677
+ .list ul {
678
+ list-style: none;
679
+ margin: 0;
680
+ padding: 0;
681
+ display: flex;
682
+ flex-direction: column;
683
+ gap: var(--uui-size-space-2, 6px);
684
+ }
685
+ .entry {
686
+ width: 100%;
687
+ text-align: left;
688
+ font: inherit;
689
+ cursor: pointer;
690
+ background: none;
691
+ padding: var(--uui-size-space-3, 9px);
692
+ border: 1px solid var(--uui-color-border, #d8d7d9);
693
+ border-radius: var(--uui-border-radius, 3px);
694
+ display: flex;
695
+ flex-direction: column;
696
+ gap: 2px;
697
+ }
698
+ .entry:hover {
699
+ border-color: var(--uui-color-emphasis, #a1a1a1);
700
+ }
701
+ .entry.selected {
702
+ border-color: var(--uui-color-selected, #3544b1);
703
+ background: var(--uui-color-surface-alt, #f3f3f5);
704
+ }
705
+ .entry.applied,
706
+ .entry.discarded {
707
+ opacity: 0.55;
708
+ }
709
+ .entry .kind {
710
+ font-size: var(--uui-type-small-size, 12px);
711
+ color: var(--uui-color-text-alt, #515054);
712
+ }
713
+ .entry .summary {
714
+ font-weight: bold;
715
+ overflow-wrap: anywhere;
716
+ }
717
+ .entry .tags {
718
+ display: flex;
719
+ gap: 4px;
720
+ }
721
+ .conversation {
722
+ max-height: 34%;
723
+ overflow: auto;
724
+ padding: var(--uui-size-space-3, 9px);
725
+ border-top: 1px solid var(--uui-color-border, #d8d7d9);
726
+ display: flex;
727
+ flex-direction: column;
728
+ gap: var(--uui-size-space-2, 6px);
729
+ }
730
+ .turn {
731
+ padding: var(--uui-size-space-2, 6px) var(--uui-size-space-3, 9px);
732
+ border-radius: var(--uui-border-radius, 3px);
733
+ white-space: pre-wrap;
734
+ font-size: var(--uui-type-small-size, 12px);
735
+ }
736
+ .turn.user {
737
+ background: var(--uui-color-surface-alt, #f3f3f5);
738
+ }
739
+ .turn.assistant {
740
+ background: var(--uui-color-divider, #f3f3f5);
741
+ }
742
+ form {
743
+ display: flex;
744
+ flex-direction: column;
745
+ gap: var(--uui-size-space-2, 6px);
746
+ padding: var(--uui-size-space-3, 9px);
747
+ border-top: 1px solid var(--uui-color-border, #d8d7d9);
748
+ }
749
+ textarea {
750
+ font: inherit;
751
+ padding: var(--uui-size-space-2, 6px);
752
+ border: 1px solid var(--uui-color-border, #d8d7d9);
753
+ border-radius: var(--uui-border-radius, 3px);
754
+ resize: vertical;
755
+ }
756
+ .detail,
757
+ .empty {
758
+ flex: 1;
759
+ min-width: 0;
760
+ display: flex;
761
+ flex-direction: column;
762
+ }
763
+ .empty {
764
+ align-items: center;
765
+ justify-content: center;
766
+ color: var(--uui-color-text-alt, #515054);
767
+ }
768
+ .detail-head {
769
+ display: flex;
770
+ align-items: flex-start;
771
+ justify-content: space-between;
772
+ gap: var(--uui-size-space-4, 12px);
773
+ padding: var(--uui-size-space-4, 12px);
774
+ border-bottom: 1px solid var(--uui-color-border, #d8d7d9);
775
+ }
776
+ .actions {
777
+ display: flex;
778
+ gap: var(--uui-size-space-2, 6px);
779
+ flex: none;
780
+ }
781
+ .effect,
782
+ .hint {
783
+ margin: 4px 0 0;
784
+ font-size: var(--uui-type-small-size, 12px);
785
+ color: var(--uui-color-text-alt, #515054);
786
+ }
787
+ .editor {
788
+ flex: 1;
789
+ min-height: 320px;
790
+ display: flex;
791
+ }
792
+ umb-code-editor {
793
+ flex: 1;
794
+ min-height: 320px;
795
+ }
796
+ .values {
797
+ flex: 1;
798
+ overflow: auto;
799
+ padding: var(--uui-size-space-4, 12px);
800
+ display: flex;
801
+ flex-direction: column;
802
+ gap: var(--uui-size-space-3, 9px);
803
+ }
804
+ .value {
805
+ display: flex;
806
+ flex-direction: column;
807
+ gap: 4px;
808
+ }
809
+ .value span {
810
+ font-weight: bold;
811
+ font-size: var(--uui-type-small-size, 12px);
812
+ }
813
+ .described {
814
+ padding: var(--uui-size-space-4, 12px);
815
+ border-bottom: 1px solid var(--uui-color-border, #d8d7d9);
816
+ }
817
+ .described ul {
818
+ margin: 0;
819
+ padding-left: 1.1rem;
820
+ display: flex;
821
+ flex-direction: column;
822
+ gap: 2px;
823
+ }
824
+ .described li {
825
+ font-size: var(--uui-type-small-size, 12px);
826
+ }
827
+ .readonly {
828
+ flex: 1;
829
+ margin: 0;
830
+ padding: var(--uui-size-space-4, 12px);
831
+ overflow: auto;
832
+ background: var(--uui-color-surface-alt, #f3f3f5);
833
+ white-space: pre-wrap;
834
+ }
835
+ .save {
836
+ display: flex;
837
+ align-items: center;
838
+ gap: var(--uui-size-space-3, 9px);
839
+ padding: var(--uui-size-space-3, 9px) var(--uui-size-space-4, 12px);
840
+ border-top: 1px solid var(--uui-color-border, #d8d7d9);
841
+ }
842
+ .problems {
843
+ margin: 0;
844
+ padding: var(--uui-size-space-3, 9px) var(--uui-size-space-4, 12px)
845
+ var(--uui-size-space-3, 9px) calc(var(--uui-size-space-4, 12px) + 16px);
846
+ color: var(--uui-color-danger, #d42054);
847
+ font-size: var(--uui-type-small-size, 12px);
848
+ }
849
+ .error {
850
+ color: var(--uui-color-danger, #d42054);
851
+ }
852
+ .unavailable {
853
+ border-bottom: 1px solid var(--uui-color-danger, #d42054);
854
+ }
855
+ .unavailable p {
856
+ margin: var(--uui-size-space-2, 6px) 0;
857
+ }
858
+ .unavailable .remedy {
859
+ font-family: var(--uui-font-monospace, monospace);
860
+ overflow-wrap: anywhere;
861
+ }
862
+ `
863
+ }
864
+
865
+ customElements.define('crombolo-assistant-drawer', CromboloAssistantDrawerElement)