dsh-edit-turn 0.2.18 → 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.18'
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': '这个会话当前未激活,请先打开该会话再编辑。',
@@ -82,6 +92,11 @@ window.__ModuleLoader__.load({
82
92
  'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
83
93
  'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
84
94
  'error.internal': '服务器内部错误,请稍后重试。',
95
+ 'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
96
+ 'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
97
+ 'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
98
+ 'error.attachment-too-large': '这个文件太大,无法作为附件。',
99
+ 'error.too-large': '这次修改太大,服务器没有接收。',
85
100
  }
86
101
 
87
102
  const en = {
@@ -102,6 +117,8 @@ window.__ModuleLoader__.load({
102
117
  'editor.pending': 'Working...',
103
118
  'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
104
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',
105
122
  'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
106
123
  'error.invalid': 'Invalid request; refresh and try again.',
107
124
  'error.session-not-active': 'This session is not open in DSH; open it first.',
@@ -123,6 +140,11 @@ window.__ModuleLoader__.load({
123
140
  'error.already-retired': 'This turn is no longer part of the current context.',
124
141
  'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
125
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.',
126
148
  }
127
149
 
128
150
  // Every error code the host can return travels through here. A code is
@@ -231,6 +253,25 @@ window.__ModuleLoader__.load({
231
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}',
232
254
  '.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
233
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}',
234
275
  '.dshet-note,.dshet-warn,.dshet-error{margin:4px 14px 0;font-size:12px;line-height:19px}',
235
276
  '.dshet-note{color:var(--dshet-ink-dim)}',
236
277
  '.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
@@ -305,6 +346,16 @@ window.__ModuleLoader__.load({
305
346
  this.editorRow = null
306
347
  this.editorFocus = false
307
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
308
359
  this.clearTimer = null
309
360
  this.pendingRerun = false
310
361
  this.siblingRerun = 'unknown'
@@ -321,6 +372,7 @@ window.__ModuleLoader__.load({
321
372
  repliesByMessage: new Map(),
322
373
  editing: null,
323
374
  draft: '',
375
+ parts: [],
324
376
  confirming: false,
325
377
  pending: false,
326
378
  failure: null,
@@ -328,6 +380,12 @@ window.__ModuleLoader__.load({
328
380
  surfaceReady: false,
329
381
  loaded: false,
330
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,
331
389
  // Matches the host's default: one click applies. Only an explicit
332
390
  // `confirm: true` in the profile turns the second step back on.
333
391
  confirmStep: false,
@@ -412,6 +470,10 @@ window.__ModuleLoader__.load({
412
470
  loaded: true,
413
471
  loadError: false,
414
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),
415
477
  })
416
478
  })
417
479
  .catch(() => {
@@ -432,7 +494,10 @@ window.__ModuleLoader__.load({
432
494
  const previous = this.editorElement
433
495
  this.load()
434
496
  this.editor = target
435
- if (!same) this.editorDraft = target.text
497
+ if (!same) {
498
+ this.editorDraft = target.text
499
+ this.editorParts = blocksToParts(target, this.sessionId)
500
+ }
436
501
  this.confirming = false
437
502
  this.editorElement = null
438
503
  // The intent to keep the user's caret in this box. It is set when the
@@ -448,7 +513,7 @@ window.__ModuleLoader__.load({
448
513
  /* focus is best-effort */
449
514
  }
450
515
  }
451
- 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 })
452
517
  this.probeSiblingRerun()
453
518
  }
454
519
 
@@ -485,15 +550,81 @@ window.__ModuleLoader__.load({
485
550
  this.editorFocus = false
486
551
  this.editor = null
487
552
  this.editorDraft = ''
553
+ this.editorParts = []
488
554
  this.confirming = false
489
555
  this.editorElement = null
490
- this.publish({ editing: null, draft: '', confirming: false, failure: null })
556
+ this.publish({ editing: null, draft: '', parts: [], confirming: false, failure: null })
491
557
  }
492
558
 
493
559
  setDraft(value) {
494
560
  this.editorDraft = value
495
561
  }
496
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
+
497
628
  review() {
498
629
  this.pendingRerun = false
499
630
  this.runReview()
@@ -534,6 +665,13 @@ window.__ModuleLoader__.load({
534
665
  this.publish({ failure: 'empty' })
535
666
  return
536
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
537
675
  this.publish({ pending: true, failure: null })
538
676
  try {
539
677
  const res = await fetch(`${ROUTE_PREFIX}/apply`, {
@@ -544,6 +682,7 @@ window.__ModuleLoader__.load({
544
682
  seq: target.seq,
545
683
  messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
546
684
  text,
685
+ ...(parts === undefined ? {} : { parts }),
547
686
  }),
548
687
  })
549
688
  const data = await res.json().catch(() => ({}))
@@ -574,6 +713,11 @@ window.__ModuleLoader__.load({
574
713
  messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
575
714
  text,
576
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 : [],
577
721
  })
578
722
  }
579
723
  // A reply edit shadows its own reply too, so it leaves the editable
@@ -586,6 +730,7 @@ window.__ModuleLoader__.load({
586
730
  this.animateOnce = true
587
731
  this.editor = null
588
732
  this.editorDraft = ''
733
+ this.editorParts = []
589
734
  this.editorElement = null
590
735
  this.editorBox = null
591
736
  this.editorRow = null
@@ -599,6 +744,7 @@ window.__ModuleLoader__.load({
599
744
  revisions,
600
745
  editable,
601
746
  replies,
747
+ parts: [],
602
748
  failure: null,
603
749
  })
604
750
  // `unchanged` means the host wrote NOTHING: the draft is (verbatim)
@@ -620,6 +766,11 @@ window.__ModuleLoader__.load({
620
766
  // failed second half has to be reported somewhere that survives that.
621
767
  if (data.kind === 'reply' && data.applied === false) this.notify('reply')
622
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')
623
774
  }
624
775
  // Save first, re-run second - always: the sibling re-runs the turn
625
776
  // from the prompt the surface NOW shows, so the revised wording has
@@ -727,6 +878,10 @@ window.__ModuleLoader__.load({
727
878
  messageId: entry.messageId,
728
879
  text: typeof entry.text === 'string' ? entry.text : '',
729
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 : [],
730
885
  }
731
886
  }
732
887
  return null
@@ -1688,6 +1843,94 @@ window.__ModuleLoader__.load({
1688
1843
  // injecting a second controlled tree into it would fight its reconciler.
1689
1844
  // The draft is mirrored into the controller on every input event, so a
1690
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
+
1691
1934
  // The re-render marker for the in-place editor.
1692
1935
  //
1693
1936
  // Every field `renderEditor` reads belongs here. A marker that only tracks
@@ -1704,6 +1947,13 @@ window.__ModuleLoader__.load({
1704
1947
  // The re-run button appears when the sibling probe lands; without it in
1705
1948
  // the marker the editor would never be rebuilt to show the button.
1706
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('.'),
1707
1957
  ].join('|')
1708
1958
  }
1709
1959
 
@@ -1722,6 +1972,100 @@ window.__ModuleLoader__.load({
1722
1972
  }
1723
1973
  }
1724
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
+
1725
2069
  function renderEditor(row, controller, view, t) {
1726
2070
  const target = view.editing
1727
2071
  const texts = editorTexts(t, target)
@@ -1794,6 +2138,18 @@ window.__ModuleLoader__.load({
1794
2138
  })
1795
2139
  box.appendChild(area)
1796
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
+
1797
2153
  // Only what the user has to know right now: what the replacement will
1798
2154
  // discard, and what went wrong. The running explanation lives in the
1799
2155
  // README and in the buttons' tooltips, the way a normal input box does it.
@@ -1803,7 +2159,9 @@ window.__ModuleLoader__.load({
1803
2159
  note.textContent = t('confirm.body')
1804
2160
  box.appendChild(note)
1805
2161
  }
1806
- 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) {
1807
2165
  const warn = document.createElement('p')
1808
2166
  warn.className = 'dshet-warn'
1809
2167
  warn.textContent = texts.warn
@@ -2029,6 +2387,10 @@ window.__ModuleLoader__.load({
2029
2387
  messageId: entry.messageId,
2030
2388
  text: typeof entry.text === 'string' ? entry.text : '',
2031
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: [],
2032
2394
  })
2033
2395
  }
2034
2396
  // The config object is always an object: this runtime's `jsx` reads