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