dsh-edit-turn 0.2.17 → 0.2.19

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/lib/client.js CHANGED
@@ -28,13 +28,21 @@ window.__ModuleLoader__.load({
28
28
 
29
29
  const NS = 'dsh-edit-turn'
30
30
  const ROUTE_PREFIX = '/dsh-edit-turn'
31
+ // One picked file may not exceed this. It mirrors the platform's own default
32
+ // per-image ceiling (dsh-attachment-local's 20 MiB), because a payload above
33
+ // it would be refused by the store anyway - saying so in the picker is
34
+ // kinder than a round trip that fails.
35
+ const MAX_ATTACH_BYTES = 20 * 1024 * 1024
36
+ // Above this a chip keeps its label instead of trying to draw itself: a
37
+ // thumbnail is not worth decoding a 30 MB attachment for.
38
+ const MAX_THUMB_BYTES = 8 * 1024 * 1024
31
39
  // The sibling that owns re-running: it shadows a turn and regenerates it
32
40
  // from the prompt the surface now shows, then replays what followed. The
33
41
  // editor's re-run button is only offered when it is mounted.
34
42
  const RERUN_PREFIX = '/dsh-rerun-turn'
35
43
 
36
44
  /** Keep in sync with package.json and lib/index.js. */
37
- const PLUGIN_VERSION = '0.2.17'
45
+ const PLUGIN_VERSION = '0.2.19'
38
46
 
39
47
  // --- copy -----------------------------------------------------------------
40
48
 
@@ -61,6 +69,8 @@ window.__ModuleLoader__.load({
61
69
  'editor.pending': '正在保存…',
62
70
  'editor.warn.attachments': '这条消息包含图片或文件附件,改写会丢弃它们,只保留文字。',
63
71
  'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
72
+ 'editor.attach.add': '添加附件',
73
+ 'editor.attach.remove': '移除这个附件',
64
74
  'confirm.body': '确认后会立即执行:这条消息之后的内容会从模型上下文中移除。原始会话日志不会改写。',
65
75
  'error.invalid': '请求无效,请刷新后重试。',
66
76
  'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
@@ -75,12 +85,18 @@ window.__ModuleLoader__.load({
75
85
  'error.prompt': '已回退,但改后的内容没能写进上下文。请重试。',
76
86
  'error.reply': '已回退,但替换没有落地。请重试。',
77
87
  'error.empty': '改写后的内容不能为空。',
88
+ 'error.unchanged': '内容没有变化,未做任何修改。',
78
89
  'error.rerun-nothing': '已保存,但这一轮没有可重跑的作答。',
79
90
  'error.rerun-unavailable': '已保存,但重跑没有开始,请稍后重试。',
80
91
  'error.not-rerunnable': '这一轮没有可重跑的作答。',
81
92
  'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
82
93
  'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
83
94
  'error.internal': '服务器内部错误,请稍后重试。',
95
+ 'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
96
+ 'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
97
+ 'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
98
+ 'error.attachment-too-large': '这个文件太大,无法作为附件。',
99
+ 'error.too-large': '这次修改太大,服务器没有接收。',
84
100
  }
85
101
 
86
102
  const en = {
@@ -101,6 +117,8 @@ window.__ModuleLoader__.load({
101
117
  'editor.pending': 'Working...',
102
118
  'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
103
119
  'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
120
+ 'editor.attach.add': 'Add an attachment',
121
+ 'editor.attach.remove': 'Remove this attachment',
104
122
  'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
105
123
  'error.invalid': 'Invalid request; refresh and try again.',
106
124
  'error.session-not-active': 'This session is not open in DSH; open it first.',
@@ -115,12 +133,18 @@ window.__ModuleLoader__.load({
115
133
  'error.prompt': 'Rolled back, but the re-run did not start. Send the revised text manually.',
116
134
  'error.reply': 'Rolled back, but the replacement did not land. Try again.',
117
135
  'error.empty': 'The revised message cannot be empty.',
136
+ 'error.unchanged': 'Nothing changed, so no edit was made.',
118
137
  'error.rerun-nothing': 'Saved, but this turn has no reply that can be re-run.',
119
138
  'error.rerun-unavailable': 'Saved, but the re-run did not start; try again.',
120
139
  'error.not-rerunnable': 'This turn has no reply that can be re-run.',
121
140
  'error.already-retired': 'This turn is no longer part of the current context.',
122
141
  'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
123
142
  'error.internal': 'The host hit an internal error; try again shortly.',
143
+ 'error.attachment-refused': 'The attachment could not be stored, so nothing was changed.',
144
+ 'error.attachments-dropped': 'Saved, but the images or files on this message could not be kept: this deployment mounts no attachment store.',
145
+ 'error.attachment-unreadable': 'That attachment could not be read; try another file.',
146
+ 'error.attachment-too-large': 'That file is too large to attach.',
147
+ 'error.too-large': 'That revision was too large for the host to accept.',
124
148
  }
125
149
 
126
150
  // Every error code the host can return travels through here. A code is
@@ -229,6 +253,25 @@ window.__ModuleLoader__.load({
229
253
  '.dshet-editor textarea{display:block;width:100%;min-height:47px;max-height:46vh;overflow-y:auto;resize:none;box-sizing:border-box;padding:11px 14px 0;border:0;background:transparent;color:inherit;font:inherit;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));outline:none}',
230
254
  '.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
231
255
  '.dshet-editor textarea:disabled{opacity:.65}',
256
+ // The blocks this message carries: one chip each, drawn from the same
257
+ // tokens as the panel so a skin cannot wash them out. A chip only takes
258
+ // the width it needs; the label is the only part that may shrink, and the
259
+ // remove button never does.
260
+ '.dshet-chips{display:flex;flex-wrap:wrap;gap:6px;margin:8px 14px 0}',
261
+ '.dshet-chip{display:inline-flex;align-items:center;gap:6px;max-width:100%;padding:3px 4px 3px 6px;border:1px solid var(--dshet-line);border-radius:10px;background:var(--dshet-chip);color:var(--dshet-ink);font-size:12px;line-height:18px}',
262
+ '.dshet-thumb{width:26px;height:26px;flex:none;border-radius:6px;object-fit:cover;background:var(--dshet-field)}',
263
+ '.dshet-chip-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
264
+ '.dshet-chip-size{flex:none;color:var(--dshet-ink-dim)}',
265
+ '.dshet-chip-remove{flex:none;width:18px;height:18px;padding:0;border:0;border-radius:999px;background:transparent;color:var(--dshet-ink-dim);font:inherit;font-size:13px;line-height:16px;cursor:pointer}',
266
+ '.dshet-chip-remove:hover:not(:disabled){background:var(--dshet-hover);color:var(--dsw-alias-state-error-primary,#c93a31)}',
267
+ '.dshet-chip-remove:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:1px}',
268
+ '.dshet-chip-remove:disabled{cursor:default;opacity:.5}',
269
+ '.dshet-attach-row{display:flex;align-items:center;gap:8px;margin:8px 14px 0}',
270
+ '.dshet-attach{padding:3px 10px;border:1px dashed var(--dshet-line);border-radius:999px;background:transparent;color:var(--dshet-ink-dim);font:inherit;font-size:12px;line-height:18px;cursor:pointer;transition:background-color .12s,color .12s}',
271
+ '.dshet-attach:hover:not(:disabled){background:var(--dshet-hover);color:var(--dshet-ink)}',
272
+ '.dshet-attach:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
273
+ '.dshet-attach:disabled{cursor:default;opacity:.5}',
274
+ '.dshet-attach-input{display:none}',
232
275
  '.dshet-note,.dshet-warn,.dshet-error{margin:4px 14px 0;font-size:12px;line-height:19px}',
233
276
  '.dshet-note{color:var(--dshet-ink-dim)}',
234
277
  '.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
@@ -303,6 +346,16 @@ window.__ModuleLoader__.load({
303
346
  this.editorRow = null
304
347
  this.editorFocus = false
305
348
  this.editorDraft = ''
349
+ // The block list being edited: what the carrier will be rebuilt from.
350
+ // Every entry is either `{ keep: <index> }` - a block of this message the
351
+ // user left alone - or `{ add: { data, mediaType?, name } }` for
352
+ // something they just picked, with the display fields the chip needs.
353
+ // The list is the ONLY thing that decides what the host writes, which is
354
+ // why a chip the user removes simply leaves it.
355
+ this.editorParts = []
356
+ // Names the added chips, so removing one is a matter of identity rather
357
+ // than of position (a DOM pass may run between two of them).
358
+ this.uploadSeq = 0
306
359
  this.clearTimer = null
307
360
  this.pendingRerun = false
308
361
  this.siblingRerun = 'unknown'
@@ -319,6 +372,7 @@ window.__ModuleLoader__.load({
319
372
  repliesByMessage: new Map(),
320
373
  editing: null,
321
374
  draft: '',
375
+ parts: [],
322
376
  confirming: false,
323
377
  pending: false,
324
378
  failure: null,
@@ -326,6 +380,12 @@ window.__ModuleLoader__.load({
326
380
  surfaceReady: false,
327
381
  loaded: false,
328
382
  loadError: false,
383
+ // What the host said it can do with a block list (see /state's
384
+ // `capabilities`). False is the safe reading: an older host, a failed
385
+ // load and a deployment without an attachment store all mean "text
386
+ // only", which is what the editor then offers - with the warning that
387
+ // says so - instead of a chip it cannot honour.
388
+ attachmentsOk: false,
329
389
  // Matches the host's default: one click applies. Only an explicit
330
390
  // `confirm: true` in the profile turns the second step back on.
331
391
  confirmStep: false,
@@ -410,6 +470,10 @@ window.__ModuleLoader__.load({
410
470
  loaded: true,
411
471
  loadError: false,
412
472
  confirmStep: !(data.config && data.config.confirm === false),
473
+ // What this host can do with blocks, taken from its own answer.
474
+ // An older host says nothing here, which reads as "text only" -
475
+ // the behaviour every release before blocks had.
476
+ attachmentsOk: Boolean(data.capabilities && data.capabilities.attachments === true),
413
477
  })
414
478
  })
415
479
  .catch(() => {
@@ -430,7 +494,10 @@ window.__ModuleLoader__.load({
430
494
  const previous = this.editorElement
431
495
  this.load()
432
496
  this.editor = target
433
- if (!same) this.editorDraft = target.text
497
+ if (!same) {
498
+ this.editorDraft = target.text
499
+ this.editorParts = blocksToParts(target, this.sessionId)
500
+ }
434
501
  this.confirming = false
435
502
  this.editorElement = null
436
503
  // The intent to keep the user's caret in this box. It is set when the
@@ -446,7 +513,7 @@ window.__ModuleLoader__.load({
446
513
  /* focus is best-effort */
447
514
  }
448
515
  }
449
- this.publish({ editing: target, draft: this.editorDraft, confirming: false, failure: null })
516
+ this.publish({ editing: target, draft: this.editorDraft, parts: this.editorParts.slice(), confirming: false, failure: null })
450
517
  this.probeSiblingRerun()
451
518
  }
452
519
 
@@ -483,15 +550,81 @@ window.__ModuleLoader__.load({
483
550
  this.editorFocus = false
484
551
  this.editor = null
485
552
  this.editorDraft = ''
553
+ this.editorParts = []
486
554
  this.confirming = false
487
555
  this.editorElement = null
488
- this.publish({ editing: null, draft: '', confirming: false, failure: null })
556
+ this.publish({ editing: null, draft: '', parts: [], confirming: false, failure: null })
489
557
  }
490
558
 
491
559
  setDraft(value) {
492
560
  this.editorDraft = value
493
561
  }
494
562
 
563
+ /**
564
+ * One chip the user removed leaves the block list, and nothing else.
565
+ *
566
+ * The list IS the edit: a block that is no longer in it is not written
567
+ * when the carrier is rebuilt, so removing a chip is the whole
568
+ * implementation of "this attachment should not be here any more".
569
+ */
570
+ removePart(key) {
571
+ const next = this.editorParts.filter((part) => part.key !== key)
572
+ if (next.length === this.editorParts.length) return
573
+ this.editorParts = next
574
+ this.publish({ parts: next.slice() })
575
+ }
576
+
577
+ /** One payload the user picked joins the block list, at the end. */
578
+ addUpload(upload) {
579
+ this.editorParts = [...this.editorParts, upload]
580
+ this.publish({ parts: this.editorParts.slice() })
581
+ }
582
+
583
+ /**
584
+ * Read picked files and append one chip each.
585
+ *
586
+ * The bytes stay in the browser until the save: the host admits them
587
+ * through the platform's attachment store only when the user commits, so
588
+ * opening the editor and cancelling stores nothing.
589
+ */
590
+ attachFiles(files) {
591
+ let list = []
592
+ try {
593
+ list = files === null || files === undefined ? [] : Array.prototype.slice.call(files)
594
+ } catch {
595
+ list = []
596
+ }
597
+ for (const file of list) {
598
+ if (!file || typeof file !== 'object') continue
599
+ if (typeof file.size === 'number' && file.size > MAX_ATTACH_BYTES) {
600
+ this.notify('attachment-too-large')
601
+ continue
602
+ }
603
+ const name = typeof file.name === 'string' && file.name !== '' ? file.name : null
604
+ const mediaType = typeof file.type === 'string' && file.type !== '' ? file.type : null
605
+ readAsBase64(file, (data) => {
606
+ if (data === null) {
607
+ this.notify('attachment-unreadable')
608
+ return
609
+ }
610
+ this.uploadSeq += 1
611
+ this.addUpload({
612
+ key: `a${this.uploadSeq}`,
613
+ add: {
614
+ data,
615
+ ...(mediaType === null ? {} : { mediaType }),
616
+ ...(name === null ? {} : { name }),
617
+ },
618
+ // The chip's own fields. A picked image can show itself through
619
+ // the very bytes it is carrying - no round trip, no store.
620
+ label: name !== null ? name : mediaType !== null ? mediaType : 'attachment',
621
+ bytes: typeof file.size === 'number' ? file.size : null,
622
+ thumbnail: `data:${mediaType === null ? 'application/octet-stream' : mediaType};base64,${data}`,
623
+ })
624
+ })
625
+ }
626
+ }
627
+
495
628
  review() {
496
629
  this.pendingRerun = false
497
630
  this.runReview()
@@ -532,6 +665,13 @@ window.__ModuleLoader__.load({
532
665
  this.publish({ failure: 'empty' })
533
666
  return
534
667
  }
668
+ // What the host will rebuild the carrier from: the blocks still on the
669
+ // list, in order, plus anything picked. Sent only when the host answered
670
+ // that it can carry blocks - a request that names blocks this host would
671
+ // have to drop is a request that lies about what the save did.
672
+ const parts = target.mode === 'prompt' && this.view.attachmentsOk === true
673
+ ? this.editorParts.map((part) => (part.keep !== undefined ? { keep: part.keep } : { add: part.add }))
674
+ : undefined
535
675
  this.publish({ pending: true, failure: null })
536
676
  try {
537
677
  const res = await fetch(`${ROUTE_PREFIX}/apply`, {
@@ -542,6 +682,7 @@ window.__ModuleLoader__.load({
542
682
  seq: target.seq,
543
683
  messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
544
684
  text,
685
+ ...(parts === undefined ? {} : { parts }),
545
686
  }),
546
687
  })
547
688
  const data = await res.json().catch(() => ({}))
@@ -572,6 +713,11 @@ window.__ModuleLoader__.load({
572
713
  messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
573
714
  text,
574
715
  attachments: typeof target.attachments === 'number' ? target.attachments : 0,
716
+ // The host answers with what the carrier now carries, described
717
+ // the way /state describes it. Adopted for the row drawn before
718
+ // the refresh lands: without it a second save could not know
719
+ // which blocks are still there and would rewrite text-only.
720
+ blocks: Array.isArray(data.blocks) ? data.blocks : [],
575
721
  })
576
722
  }
577
723
  // A reply edit shadows its own reply too, so it leaves the editable
@@ -584,6 +730,7 @@ window.__ModuleLoader__.load({
584
730
  this.animateOnce = true
585
731
  this.editor = null
586
732
  this.editorDraft = ''
733
+ this.editorParts = []
587
734
  this.editorElement = null
588
735
  this.editorBox = null
589
736
  this.editorRow = null
@@ -597,12 +744,34 @@ window.__ModuleLoader__.load({
597
744
  revisions,
598
745
  editable,
599
746
  replies,
747
+ parts: [],
600
748
  failure: null,
601
749
  })
602
- // The rollback has landed, so the editor is gone with its row: a
603
- // failed second half has to be reported somewhere that survives that.
604
- if (data.kind === 'reply' && data.applied === false) this.notify('reply')
605
- if (data.kind !== 'reply' && data.applied === false) this.notify('prompt')
750
+ // `unchanged` means the host wrote NOTHING: the draft is (verbatim)
751
+ // the text the message already carries. There is no failure to report -
752
+ // the edit was simply not an edit - so it is noticed as such, and
753
+ // nothing else about this pass changes: no row was shadowed, so none is
754
+ // hidden, and the message stays on screen with the pencil it had.
755
+ //
756
+ // The re-run intent survives it. "Re-run" saves first only because the
757
+ // sibling regenerates the turn from the prompt the surface shows; a
758
+ // user who pressed it wants that turn regenerated whether or not the
759
+ // wording moved, so the chain below is NOT gated on a write having
760
+ // happened. (The decision itself is the host's - the text is parsed
761
+ // there, once - and this half only reads the flag it sends back.)
762
+ if (data.unchanged === true) {
763
+ if (!rerunIntent) this.notify('unchanged')
764
+ } else {
765
+ // The rollback has landed, so the editor is gone with its row: a
766
+ // failed second half has to be reported somewhere that survives that.
767
+ if (data.kind === 'reply' && data.applied === false) this.notify('reply')
768
+ if (data.kind !== 'reply' && data.applied === false) this.notify('prompt')
769
+ // The save landed, but it left non-text blocks behind (the host says
770
+ // so): the editor that could have warned is gone by now, so this is
771
+ // the only place the user can still be told (see applyEdit's
772
+ // `dropped`).
773
+ if (data.kind !== 'reply' && data.applied === true && data.dropped === true) this.notify('attachments-dropped')
774
+ }
606
775
  // Save first, re-run second - always: the sibling re-runs the turn
607
776
  // from the prompt the surface NOW shows, so the revised wording has
608
777
  // to be on the surface before it is asked to regenerate. Not
@@ -709,6 +878,10 @@ window.__ModuleLoader__.load({
709
878
  messageId: entry.messageId,
710
879
  text: typeof entry.text === 'string' ? entry.text : '',
711
880
  attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
881
+ // The blocks this message carries, as /state described them. They seed
882
+ // the editor's chips; a host that does not describe them (or an
883
+ // optimistic entry that predates its refresh) simply has none.
884
+ blocks: Array.isArray(entry.blocks) ? entry.blocks : [],
712
885
  }
713
886
  }
714
887
  return null
@@ -1670,6 +1843,94 @@ window.__ModuleLoader__.load({
1670
1843
  // injecting a second controlled tree into it would fight its reconciler.
1671
1844
  // The draft is mirrored into the controller on every input event, so a
1672
1845
  // host re-render that wipes the textarea cannot lose what was typed.
1846
+ // --- block chips ----------------------------------------------------------
1847
+ //
1848
+ // A message's non-text blocks, as the editor shows them: one chip per block,
1849
+ // each with what the block itself reported about itself (a name, a size, and
1850
+ // whether its bytes are reachable) and a remove button. Nothing here reads a
1851
+ // block TYPE: a chip is drawn from the fields a block carries, so a block
1852
+ // kind the platform adds later shows up with its own name and needs no
1853
+ // change in this file.
1854
+
1855
+ /** One chip's display fields, taken from whatever the block said about itself. */
1856
+ function chipOf(block) {
1857
+ const name = typeof block.name === 'string' && block.name !== '' ? block.name : null
1858
+ const mediaType = typeof block.mediaType === 'string' && block.mediaType !== '' ? block.mediaType : null
1859
+ const type = typeof block.type === 'string' && block.type !== '' ? block.type : 'block'
1860
+ const bytes = typeof block.bytes === 'number' ? block.bytes : null
1861
+ return { label: name !== null ? name : mediaType !== null ? mediaType : type, bytes }
1862
+ }
1863
+
1864
+ /**
1865
+ * The block list an editor starts from: every block of the message, kept.
1866
+ *
1867
+ * A reply starts from none. What a reply carries beside its text - tool
1868
+ * calls, reasoning - cannot survive a replacement (their results no longer
1869
+ * hold), so offering a chip for them would promise something the edit cannot
1870
+ * keep; the editor warns about them instead.
1871
+ */
1872
+ function blocksToParts(target, sessionId) {
1873
+ if (!target || target.mode !== 'prompt') return []
1874
+ const blocks = Array.isArray(target.blocks) ? target.blocks : []
1875
+ const parts = []
1876
+ for (const block of blocks) {
1877
+ if (!block || typeof block.index !== 'number') continue
1878
+ const chip = chipOf(block)
1879
+ parts.push({
1880
+ keep: block.index,
1881
+ key: `k${block.index}`,
1882
+ label: chip.label,
1883
+ bytes: chip.bytes,
1884
+ thumbnail: block.preview === true ? previewUrl(sessionId, target.seq, block.index) : null,
1885
+ })
1886
+ }
1887
+ return parts
1888
+ }
1889
+
1890
+ /** Where one stored block's bytes are read from, for its thumbnail. */
1891
+ function previewUrl(sessionId, seq, index) {
1892
+ return `${ROUTE_PREFIX}/attachment?sessionId=${encodeURIComponent(sessionId)}&seq=${seq}&index=${index}`
1893
+ }
1894
+
1895
+ /** A byte count a human reads at a glance. */
1896
+ function formatBytes(bytes) {
1897
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return ''
1898
+ if (bytes < 1024) return `${bytes} B`
1899
+ const units = ['KB', 'MB', 'GB']
1900
+ let value = bytes / 1024
1901
+ let unit = 0
1902
+ while (value >= 1024 && unit < units.length - 1) {
1903
+ value /= 1024
1904
+ unit += 1
1905
+ }
1906
+ return `${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10} ${units[unit]}`
1907
+ }
1908
+
1909
+ /**
1910
+ * One picked file's bytes, base64, without the Data URL prefix.
1911
+ *
1912
+ * Read here rather than at save time so a save is one request: the payload
1913
+ * travels with the revision the user committed to.
1914
+ */
1915
+ function readAsBase64(file, done) {
1916
+ if (typeof FileReader !== 'function') {
1917
+ done(null)
1918
+ return
1919
+ }
1920
+ try {
1921
+ const reader = new FileReader()
1922
+ reader.onload = () => {
1923
+ const result = typeof reader.result === 'string' ? reader.result : ''
1924
+ const comma = result.indexOf(',')
1925
+ done(comma === -1 ? null : result.slice(comma + 1))
1926
+ }
1927
+ reader.onerror = () => done(null)
1928
+ reader.readAsDataURL(file)
1929
+ } catch {
1930
+ done(null)
1931
+ }
1932
+ }
1933
+
1673
1934
  // The re-render marker for the in-place editor.
1674
1935
  //
1675
1936
  // Every field `renderEditor` reads belongs here. A marker that only tracks
@@ -1686,6 +1947,13 @@ window.__ModuleLoader__.load({
1686
1947
  // The re-run button appears when the sibling probe lands; without it in
1687
1948
  // the marker the editor would never be rebuilt to show the button.
1688
1949
  view.siblingRerun === 'present' ? 'r' : 'n',
1950
+ // Whether chips are drawn at all: a host that cannot admit blocks asks
1951
+ // for the warning instead, and the box has to change with that answer.
1952
+ view.attachmentsOk === true ? 'A' : 'a',
1953
+ // The block list itself. Removing a chip and adding one both change what
1954
+ // saving would write, so both have to redraw the strip - and the chip
1955
+ // keys are enough to tell one list from another.
1956
+ view.parts.map((part) => part.key).join('.'),
1689
1957
  ].join('|')
1690
1958
  }
1691
1959
 
@@ -1704,6 +1972,100 @@ window.__ModuleLoader__.load({
1704
1972
  }
1705
1973
  }
1706
1974
 
1975
+ /**
1976
+ * The chips: one per block on the block list.
1977
+ *
1978
+ * A chip IS the block's presence in the edit. Removing it takes the block
1979
+ * off the list, and a block that is not on the list is not written when the
1980
+ * carrier is rebuilt - the whole mechanism, and the same one for a picture,
1981
+ * a file and whatever the platform carries next. A chip draws itself from
1982
+ * the bytes the block already has, and gives up on its thumbnail when the
1983
+ * browser says they are not a picture: no type is consulted either way.
1984
+ */
1985
+ function editorChips(controller, view, t) {
1986
+ const strip = document.createElement('div')
1987
+ strip.className = 'dshet-chips'
1988
+ for (const part of view.parts) {
1989
+ const chip = document.createElement('div')
1990
+ chip.className = 'dshet-chip'
1991
+ chip.dataset.dshetChip = part.key
1992
+ const drawable = typeof part.thumbnail === 'string' && part.thumbnail !== '' &&
1993
+ (typeof part.bytes !== 'number' || part.bytes <= MAX_THUMB_BYTES)
1994
+ if (drawable) {
1995
+ const image = document.createElement('img')
1996
+ image.className = 'dshet-thumb'
1997
+ image.alt = ''
1998
+ image.src = part.thumbnail
1999
+ image.addEventListener('error', () => {
2000
+ const parent = image.parentElement
2001
+ if (typeof image.remove === 'function') image.remove()
2002
+ else if (parent !== null && parent !== undefined && typeof parent.removeChild === 'function') parent.removeChild(image)
2003
+ })
2004
+ chip.appendChild(image)
2005
+ }
2006
+ const label = document.createElement('span')
2007
+ label.className = 'dshet-chip-label'
2008
+ label.textContent = part.label
2009
+ chip.appendChild(label)
2010
+ const size = formatBytes(part.bytes)
2011
+ if (size !== '') {
2012
+ const measured = document.createElement('span')
2013
+ measured.className = 'dshet-chip-size'
2014
+ measured.textContent = size
2015
+ chip.appendChild(measured)
2016
+ }
2017
+ const remove = document.createElement('button')
2018
+ remove.type = 'button'
2019
+ remove.className = 'dshet-chip-remove'
2020
+ remove.textContent = '×'
2021
+ remove.title = t('editor.attach.remove')
2022
+ remove.setAttribute('aria-label', t('editor.attach.remove'))
2023
+ remove.disabled = view.pending
2024
+ pressable(remove, () => controller.removePart(part.key))
2025
+ chip.appendChild(remove)
2026
+ strip.appendChild(chip)
2027
+ }
2028
+ return strip
2029
+ }
2030
+
2031
+ /**
2032
+ * The add/replace entry: a picker that puts a block on the list.
2033
+ *
2034
+ * Drawn only when the host answered that it can admit a block. A deployment
2035
+ * with no attachment store gets no picker, because a picker that cannot be
2036
+ * honoured is worse than none - the warning below says what a save will
2037
+ * drop instead.
2038
+ */
2039
+ function attachEntry(controller, view, t) {
2040
+ const row = document.createElement('div')
2041
+ row.className = 'dshet-attach-row'
2042
+ const picker = document.createElement('input')
2043
+ picker.type = 'file'
2044
+ picker.multiple = true
2045
+ picker.className = 'dshet-attach-input'
2046
+ picker.style.display = 'none'
2047
+ picker.addEventListener('change', () => {
2048
+ controller.attachFiles(picker.files)
2049
+ // Cleared so the same file can be picked again after a removal.
2050
+ try {
2051
+ picker.value = ''
2052
+ } catch {
2053
+ /* not every input lets this be written */
2054
+ }
2055
+ })
2056
+ const add = document.createElement('button')
2057
+ add.type = 'button'
2058
+ add.className = 'dshet-attach'
2059
+ add.textContent = t('editor.attach.add')
2060
+ add.disabled = view.pending
2061
+ pressable(add, () => {
2062
+ if (typeof picker.click === 'function') picker.click()
2063
+ })
2064
+ row.appendChild(picker)
2065
+ row.appendChild(add)
2066
+ return row
2067
+ }
2068
+
1707
2069
  function renderEditor(row, controller, view, t) {
1708
2070
  const target = view.editing
1709
2071
  const texts = editorTexts(t, target)
@@ -1776,6 +2138,18 @@ window.__ModuleLoader__.load({
1776
2138
  })
1777
2139
  box.appendChild(area)
1778
2140
 
2141
+ // The blocks this message carries, each one a chip the user can remove,
2142
+ // and the entry that adds another. Drawn only when the host answered that
2143
+ // it can admit what a chip promises - otherwise the warning below states
2144
+ // what a save would drop, which is exactly what this editor did before
2145
+ // blocks travelled at all. A reply never gets chips: what it carries
2146
+ // beside its text cannot survive a replacement.
2147
+ const chips = target.mode === 'prompt' && view.attachmentsOk === true
2148
+ if (chips) {
2149
+ if (view.parts.length > 0) box.appendChild(editorChips(controller, view, t))
2150
+ box.appendChild(attachEntry(controller, view, t))
2151
+ }
2152
+
1779
2153
  // Only what the user has to know right now: what the replacement will
1780
2154
  // discard, and what went wrong. The running explanation lives in the
1781
2155
  // README and in the buttons' tooltips, the way a normal input box does it.
@@ -1785,7 +2159,9 @@ window.__ModuleLoader__.load({
1785
2159
  note.textContent = t('confirm.body')
1786
2160
  box.appendChild(note)
1787
2161
  }
1788
- if (target.attachments > 0) {
2162
+ // Shown when saving really would drop something: a reply's tool calls
2163
+ // always, and a prompt's blocks when this host cannot carry them.
2164
+ if (target.attachments > 0 && !chips) {
1789
2165
  const warn = document.createElement('p')
1790
2166
  warn.className = 'dshet-warn'
1791
2167
  warn.textContent = texts.warn
@@ -2011,6 +2387,10 @@ window.__ModuleLoader__.load({
2011
2387
  messageId: entry.messageId,
2012
2388
  text: typeof entry.text === 'string' ? entry.text : '',
2013
2389
  attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
2390
+ // A reply edits text only on purpose: the tool calls and reasoning it
2391
+ // carries stop being true the moment it is replaced, so they are not
2392
+ // offered as chips (the editor warns about them instead).
2393
+ blocks: [],
2014
2394
  })
2015
2395
  }
2016
2396
  // The config object is always an object: this runtime's `jsx` reads