dsh-as-aistudio 0.2.7 → 0.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-as-aistudio",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Google-AI-Studio-style turn controls for DeepSeek Harness, in one install: edit a prompt and re-run it, re-run a reply in place, delete a turn from the model context, and render sent prompts as Markdown. Self-contained: the plugin list shows this one row; the four components are dependencies mounted by it. Each still installs alone and works alone.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "dsh-delete-turn": "^0.1.8",
56
- "dsh-edit-turn": "^0.2.18",
56
+ "dsh-edit-turn": "^0.2.19",
57
57
  "dsh-markdown-bubble": "^0.1.4",
58
58
  "dsh-rerun-turn": "^0.1.25"
59
59
  },
package/src/client.js CHANGED
@@ -26,13 +26,21 @@ window.__ModuleLoader__.load({
26
26
 
27
27
  const NS = 'dsh-edit-turn'
28
28
  const ROUTE_PREFIX = '/dsh-edit-turn'
29
+ // One picked file may not exceed this. It mirrors the platform's own default
30
+ // per-image ceiling (dsh-attachment-local's 20 MiB), because a payload above
31
+ // it would be refused by the store anyway - saying so in the picker is
32
+ // kinder than a round trip that fails.
33
+ const MAX_ATTACH_BYTES = 20 * 1024 * 1024
34
+ // Above this a chip keeps its label instead of trying to draw itself: a
35
+ // thumbnail is not worth decoding a 30 MB attachment for.
36
+ const MAX_THUMB_BYTES = 8 * 1024 * 1024
29
37
  // The sibling that owns re-running: it shadows a turn and regenerates it
30
38
  // from the prompt the surface now shows, then replays what followed. The
31
39
  // editor's re-run button is only offered when it is mounted.
32
40
  const RERUN_PREFIX = '/dsh-rerun-turn'
33
41
 
34
42
  /** Keep in sync with package.json and lib/index.js. */
35
- const PLUGIN_VERSION = '0.2.18'
43
+ const PLUGIN_VERSION = '0.2.19'
36
44
 
37
45
  // --- copy -----------------------------------------------------------------
38
46
 
@@ -59,6 +67,8 @@ window.__ModuleLoader__.load({
59
67
  'editor.pending': '正在保存…',
60
68
  'editor.warn.attachments': '这条消息包含图片或文件附件,改写会丢弃它们,只保留文字。',
61
69
  'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
70
+ 'editor.attach.add': '添加附件',
71
+ 'editor.attach.remove': '移除这个附件',
62
72
  'confirm.body': '确认后会立即执行:这条消息之后的内容会从模型上下文中移除。原始会话日志不会改写。',
63
73
  'error.invalid': '请求无效,请刷新后重试。',
64
74
  'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
@@ -80,6 +90,11 @@ window.__ModuleLoader__.load({
80
90
  'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
81
91
  'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
82
92
  'error.internal': '服务器内部错误,请稍后重试。',
93
+ 'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
94
+ 'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
95
+ 'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
96
+ 'error.attachment-too-large': '这个文件太大,无法作为附件。',
97
+ 'error.too-large': '这次修改太大,服务器没有接收。',
83
98
  }
84
99
 
85
100
  const en = {
@@ -100,6 +115,8 @@ window.__ModuleLoader__.load({
100
115
  'editor.pending': 'Working...',
101
116
  'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
102
117
  'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
118
+ 'editor.attach.add': 'Add an attachment',
119
+ 'editor.attach.remove': 'Remove this attachment',
103
120
  'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
104
121
  'error.invalid': 'Invalid request; refresh and try again.',
105
122
  'error.session-not-active': 'This session is not open in DSH; open it first.',
@@ -121,6 +138,11 @@ window.__ModuleLoader__.load({
121
138
  'error.already-retired': 'This turn is no longer part of the current context.',
122
139
  'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
123
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.',
124
146
  }
125
147
 
126
148
  // Every error code the host can return travels through here. A code is
@@ -229,6 +251,25 @@ window.__ModuleLoader__.load({
229
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}',
230
252
  '.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
231
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}',
232
273
  '.dshet-note,.dshet-warn,.dshet-error{margin:4px 14px 0;font-size:12px;line-height:19px}',
233
274
  '.dshet-note{color:var(--dshet-ink-dim)}',
234
275
  '.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
@@ -303,6 +344,16 @@ window.__ModuleLoader__.load({
303
344
  this.editorRow = null
304
345
  this.editorFocus = false
305
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
306
357
  this.clearTimer = null
307
358
  this.pendingRerun = false
308
359
  this.siblingRerun = 'unknown'
@@ -319,6 +370,7 @@ window.__ModuleLoader__.load({
319
370
  repliesByMessage: new Map(),
320
371
  editing: null,
321
372
  draft: '',
373
+ parts: [],
322
374
  confirming: false,
323
375
  pending: false,
324
376
  failure: null,
@@ -326,6 +378,12 @@ window.__ModuleLoader__.load({
326
378
  surfaceReady: false,
327
379
  loaded: false,
328
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,
329
387
  // Matches the host's default: one click applies. Only an explicit
330
388
  // `confirm: true` in the profile turns the second step back on.
331
389
  confirmStep: false,
@@ -410,6 +468,10 @@ window.__ModuleLoader__.load({
410
468
  loaded: true,
411
469
  loadError: false,
412
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),
413
475
  })
414
476
  })
415
477
  .catch(() => {
@@ -430,7 +492,10 @@ window.__ModuleLoader__.load({
430
492
  const previous = this.editorElement
431
493
  this.load()
432
494
  this.editor = target
433
- if (!same) this.editorDraft = target.text
495
+ if (!same) {
496
+ this.editorDraft = target.text
497
+ this.editorParts = blocksToParts(target, this.sessionId)
498
+ }
434
499
  this.confirming = false
435
500
  this.editorElement = null
436
501
  // The intent to keep the user's caret in this box. It is set when the
@@ -446,7 +511,7 @@ window.__ModuleLoader__.load({
446
511
  /* focus is best-effort */
447
512
  }
448
513
  }
449
- 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 })
450
515
  this.probeSiblingRerun()
451
516
  }
452
517
 
@@ -483,15 +548,81 @@ window.__ModuleLoader__.load({
483
548
  this.editorFocus = false
484
549
  this.editor = null
485
550
  this.editorDraft = ''
551
+ this.editorParts = []
486
552
  this.confirming = false
487
553
  this.editorElement = null
488
- this.publish({ editing: null, draft: '', confirming: false, failure: null })
554
+ this.publish({ editing: null, draft: '', parts: [], confirming: false, failure: null })
489
555
  }
490
556
 
491
557
  setDraft(value) {
492
558
  this.editorDraft = value
493
559
  }
494
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
+
495
626
  review() {
496
627
  this.pendingRerun = false
497
628
  this.runReview()
@@ -532,6 +663,13 @@ window.__ModuleLoader__.load({
532
663
  this.publish({ failure: 'empty' })
533
664
  return
534
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
535
673
  this.publish({ pending: true, failure: null })
536
674
  try {
537
675
  const res = await fetch(`${ROUTE_PREFIX}/apply`, {
@@ -542,6 +680,7 @@ window.__ModuleLoader__.load({
542
680
  seq: target.seq,
543
681
  messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
544
682
  text,
683
+ ...(parts === undefined ? {} : { parts }),
545
684
  }),
546
685
  })
547
686
  const data = await res.json().catch(() => ({}))
@@ -572,6 +711,11 @@ window.__ModuleLoader__.load({
572
711
  messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
573
712
  text,
574
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 : [],
575
719
  })
576
720
  }
577
721
  // A reply edit shadows its own reply too, so it leaves the editable
@@ -584,6 +728,7 @@ window.__ModuleLoader__.load({
584
728
  this.animateOnce = true
585
729
  this.editor = null
586
730
  this.editorDraft = ''
731
+ this.editorParts = []
587
732
  this.editorElement = null
588
733
  this.editorBox = null
589
734
  this.editorRow = null
@@ -597,6 +742,7 @@ window.__ModuleLoader__.load({
597
742
  revisions,
598
743
  editable,
599
744
  replies,
745
+ parts: [],
600
746
  failure: null,
601
747
  })
602
748
  // `unchanged` means the host wrote NOTHING: the draft is (verbatim)
@@ -618,6 +764,11 @@ window.__ModuleLoader__.load({
618
764
  // failed second half has to be reported somewhere that survives that.
619
765
  if (data.kind === 'reply' && data.applied === false) this.notify('reply')
620
766
  if (data.kind !== 'reply' && data.applied === false) this.notify('prompt')
767
+ // The save landed, but it left non-text blocks behind (the host says
768
+ // so): the editor that could have warned is gone by now, so this is
769
+ // the only place the user can still be told (see applyEdit's
770
+ // `dropped`).
771
+ if (data.kind !== 'reply' && data.applied === true && data.dropped === true) this.notify('attachments-dropped')
621
772
  }
622
773
  // Save first, re-run second - always: the sibling re-runs the turn
623
774
  // from the prompt the surface NOW shows, so the revised wording has
@@ -725,6 +876,10 @@ window.__ModuleLoader__.load({
725
876
  messageId: entry.messageId,
726
877
  text: typeof entry.text === 'string' ? entry.text : '',
727
878
  attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
879
+ // The blocks this message carries, as /state described them. They seed
880
+ // the editor's chips; a host that does not describe them (or an
881
+ // optimistic entry that predates its refresh) simply has none.
882
+ blocks: Array.isArray(entry.blocks) ? entry.blocks : [],
728
883
  }
729
884
  }
730
885
  return null
@@ -1686,6 +1841,94 @@ window.__ModuleLoader__.load({
1686
1841
  // injecting a second controlled tree into it would fight its reconciler.
1687
1842
  // The draft is mirrored into the controller on every input event, so a
1688
1843
  // host re-render that wipes the textarea cannot lose what was typed.
1844
+ // --- block chips ----------------------------------------------------------
1845
+ //
1846
+ // A message's non-text blocks, as the editor shows them: one chip per block,
1847
+ // each with what the block itself reported about itself (a name, a size, and
1848
+ // whether its bytes are reachable) and a remove button. Nothing here reads a
1849
+ // block TYPE: a chip is drawn from the fields a block carries, so a block
1850
+ // kind the platform adds later shows up with its own name and needs no
1851
+ // change in this file.
1852
+
1853
+ /** One chip's display fields, taken from whatever the block said about itself. */
1854
+ function chipOf(block) {
1855
+ const name = typeof block.name === 'string' && block.name !== '' ? block.name : null
1856
+ const mediaType = typeof block.mediaType === 'string' && block.mediaType !== '' ? block.mediaType : null
1857
+ const type = typeof block.type === 'string' && block.type !== '' ? block.type : 'block'
1858
+ const bytes = typeof block.bytes === 'number' ? block.bytes : null
1859
+ return { label: name !== null ? name : mediaType !== null ? mediaType : type, bytes }
1860
+ }
1861
+
1862
+ /**
1863
+ * The block list an editor starts from: every block of the message, kept.
1864
+ *
1865
+ * A reply starts from none. What a reply carries beside its text - tool
1866
+ * calls, reasoning - cannot survive a replacement (their results no longer
1867
+ * hold), so offering a chip for them would promise something the edit cannot
1868
+ * keep; the editor warns about them instead.
1869
+ */
1870
+ function blocksToParts(target, sessionId) {
1871
+ if (!target || target.mode !== 'prompt') return []
1872
+ const blocks = Array.isArray(target.blocks) ? target.blocks : []
1873
+ const parts = []
1874
+ for (const block of blocks) {
1875
+ if (!block || typeof block.index !== 'number') continue
1876
+ const chip = chipOf(block)
1877
+ parts.push({
1878
+ keep: block.index,
1879
+ key: `k${block.index}`,
1880
+ label: chip.label,
1881
+ bytes: chip.bytes,
1882
+ thumbnail: block.preview === true ? previewUrl(sessionId, target.seq, block.index) : null,
1883
+ })
1884
+ }
1885
+ return parts
1886
+ }
1887
+
1888
+ /** Where one stored block's bytes are read from, for its thumbnail. */
1889
+ function previewUrl(sessionId, seq, index) {
1890
+ return `${ROUTE_PREFIX}/attachment?sessionId=${encodeURIComponent(sessionId)}&seq=${seq}&index=${index}`
1891
+ }
1892
+
1893
+ /** A byte count a human reads at a glance. */
1894
+ function formatBytes(bytes) {
1895
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return ''
1896
+ if (bytes < 1024) return `${bytes} B`
1897
+ const units = ['KB', 'MB', 'GB']
1898
+ let value = bytes / 1024
1899
+ let unit = 0
1900
+ while (value >= 1024 && unit < units.length - 1) {
1901
+ value /= 1024
1902
+ unit += 1
1903
+ }
1904
+ return `${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10} ${units[unit]}`
1905
+ }
1906
+
1907
+ /**
1908
+ * One picked file's bytes, base64, without the Data URL prefix.
1909
+ *
1910
+ * Read here rather than at save time so a save is one request: the payload
1911
+ * travels with the revision the user committed to.
1912
+ */
1913
+ function readAsBase64(file, done) {
1914
+ if (typeof FileReader !== 'function') {
1915
+ done(null)
1916
+ return
1917
+ }
1918
+ try {
1919
+ const reader = new FileReader()
1920
+ reader.onload = () => {
1921
+ const result = typeof reader.result === 'string' ? reader.result : ''
1922
+ const comma = result.indexOf(',')
1923
+ done(comma === -1 ? null : result.slice(comma + 1))
1924
+ }
1925
+ reader.onerror = () => done(null)
1926
+ reader.readAsDataURL(file)
1927
+ } catch {
1928
+ done(null)
1929
+ }
1930
+ }
1931
+
1689
1932
  // The re-render marker for the in-place editor.
1690
1933
  //
1691
1934
  // Every field `renderEditor` reads belongs here. A marker that only tracks
@@ -1702,6 +1945,13 @@ window.__ModuleLoader__.load({
1702
1945
  // The re-run button appears when the sibling probe lands; without it in
1703
1946
  // the marker the editor would never be rebuilt to show the button.
1704
1947
  view.siblingRerun === 'present' ? 'r' : 'n',
1948
+ // Whether chips are drawn at all: a host that cannot admit blocks asks
1949
+ // for the warning instead, and the box has to change with that answer.
1950
+ view.attachmentsOk === true ? 'A' : 'a',
1951
+ // The block list itself. Removing a chip and adding one both change what
1952
+ // saving would write, so both have to redraw the strip - and the chip
1953
+ // keys are enough to tell one list from another.
1954
+ view.parts.map((part) => part.key).join('.'),
1705
1955
  ].join('|')
1706
1956
  }
1707
1957
 
@@ -1720,6 +1970,100 @@ window.__ModuleLoader__.load({
1720
1970
  }
1721
1971
  }
1722
1972
 
1973
+ /**
1974
+ * The chips: one per block on the block list.
1975
+ *
1976
+ * A chip IS the block's presence in the edit. Removing it takes the block
1977
+ * off the list, and a block that is not on the list is not written when the
1978
+ * carrier is rebuilt - the whole mechanism, and the same one for a picture,
1979
+ * a file and whatever the platform carries next. A chip draws itself from
1980
+ * the bytes the block already has, and gives up on its thumbnail when the
1981
+ * browser says they are not a picture: no type is consulted either way.
1982
+ */
1983
+ function editorChips(controller, view, t) {
1984
+ const strip = document.createElement('div')
1985
+ strip.className = 'dshet-chips'
1986
+ for (const part of view.parts) {
1987
+ const chip = document.createElement('div')
1988
+ chip.className = 'dshet-chip'
1989
+ chip.dataset.dshetChip = part.key
1990
+ const drawable = typeof part.thumbnail === 'string' && part.thumbnail !== '' &&
1991
+ (typeof part.bytes !== 'number' || part.bytes <= MAX_THUMB_BYTES)
1992
+ if (drawable) {
1993
+ const image = document.createElement('img')
1994
+ image.className = 'dshet-thumb'
1995
+ image.alt = ''
1996
+ image.src = part.thumbnail
1997
+ image.addEventListener('error', () => {
1998
+ const parent = image.parentElement
1999
+ if (typeof image.remove === 'function') image.remove()
2000
+ else if (parent !== null && parent !== undefined && typeof parent.removeChild === 'function') parent.removeChild(image)
2001
+ })
2002
+ chip.appendChild(image)
2003
+ }
2004
+ const label = document.createElement('span')
2005
+ label.className = 'dshet-chip-label'
2006
+ label.textContent = part.label
2007
+ chip.appendChild(label)
2008
+ const size = formatBytes(part.bytes)
2009
+ if (size !== '') {
2010
+ const measured = document.createElement('span')
2011
+ measured.className = 'dshet-chip-size'
2012
+ measured.textContent = size
2013
+ chip.appendChild(measured)
2014
+ }
2015
+ const remove = document.createElement('button')
2016
+ remove.type = 'button'
2017
+ remove.className = 'dshet-chip-remove'
2018
+ remove.textContent = '×'
2019
+ remove.title = t('editor.attach.remove')
2020
+ remove.setAttribute('aria-label', t('editor.attach.remove'))
2021
+ remove.disabled = view.pending
2022
+ pressable(remove, () => controller.removePart(part.key))
2023
+ chip.appendChild(remove)
2024
+ strip.appendChild(chip)
2025
+ }
2026
+ return strip
2027
+ }
2028
+
2029
+ /**
2030
+ * The add/replace entry: a picker that puts a block on the list.
2031
+ *
2032
+ * Drawn only when the host answered that it can admit a block. A deployment
2033
+ * with no attachment store gets no picker, because a picker that cannot be
2034
+ * honoured is worse than none - the warning below says what a save will
2035
+ * drop instead.
2036
+ */
2037
+ function attachEntry(controller, view, t) {
2038
+ const row = document.createElement('div')
2039
+ row.className = 'dshet-attach-row'
2040
+ const picker = document.createElement('input')
2041
+ picker.type = 'file'
2042
+ picker.multiple = true
2043
+ picker.className = 'dshet-attach-input'
2044
+ picker.style.display = 'none'
2045
+ picker.addEventListener('change', () => {
2046
+ controller.attachFiles(picker.files)
2047
+ // Cleared so the same file can be picked again after a removal.
2048
+ try {
2049
+ picker.value = ''
2050
+ } catch {
2051
+ /* not every input lets this be written */
2052
+ }
2053
+ })
2054
+ const add = document.createElement('button')
2055
+ add.type = 'button'
2056
+ add.className = 'dshet-attach'
2057
+ add.textContent = t('editor.attach.add')
2058
+ add.disabled = view.pending
2059
+ pressable(add, () => {
2060
+ if (typeof picker.click === 'function') picker.click()
2061
+ })
2062
+ row.appendChild(picker)
2063
+ row.appendChild(add)
2064
+ return row
2065
+ }
2066
+
1723
2067
  function renderEditor(row, controller, view, t) {
1724
2068
  const target = view.editing
1725
2069
  const texts = editorTexts(t, target)
@@ -1792,6 +2136,18 @@ window.__ModuleLoader__.load({
1792
2136
  })
1793
2137
  box.appendChild(area)
1794
2138
 
2139
+ // The blocks this message carries, each one a chip the user can remove,
2140
+ // and the entry that adds another. Drawn only when the host answered that
2141
+ // it can admit what a chip promises - otherwise the warning below states
2142
+ // what a save would drop, which is exactly what this editor did before
2143
+ // blocks travelled at all. A reply never gets chips: what it carries
2144
+ // beside its text cannot survive a replacement.
2145
+ const chips = target.mode === 'prompt' && view.attachmentsOk === true
2146
+ if (chips) {
2147
+ if (view.parts.length > 0) box.appendChild(editorChips(controller, view, t))
2148
+ box.appendChild(attachEntry(controller, view, t))
2149
+ }
2150
+
1795
2151
  // Only what the user has to know right now: what the replacement will
1796
2152
  // discard, and what went wrong. The running explanation lives in the
1797
2153
  // README and in the buttons' tooltips, the way a normal input box does it.
@@ -1801,7 +2157,9 @@ window.__ModuleLoader__.load({
1801
2157
  note.textContent = t('confirm.body')
1802
2158
  box.appendChild(note)
1803
2159
  }
1804
- if (target.attachments > 0) {
2160
+ // Shown when saving really would drop something: a reply's tool calls
2161
+ // always, and a prompt's blocks when this host cannot carry them.
2162
+ if (target.attachments > 0 && !chips) {
1805
2163
  const warn = document.createElement('p')
1806
2164
  warn.className = 'dshet-warn'
1807
2165
  warn.textContent = texts.warn
@@ -2027,6 +2385,10 @@ window.__ModuleLoader__.load({
2027
2385
  messageId: entry.messageId,
2028
2386
  text: typeof entry.text === 'string' ? entry.text : '',
2029
2387
  attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
2388
+ // A reply edits text only on purpose: the tool calls and reasoning it
2389
+ // carries stop being true the moment it is replaced, so they are not
2390
+ // offered as chips (the editor warns about them instead).
2391
+ blocks: [],
2030
2392
  })
2031
2393
  }
2032
2394
  // The config object is always an object: this runtime's `jsx` reads
@@ -5103,7 +5465,7 @@ window.__ModuleLoader__.load({
5103
5465
  const NS = 'dsh-as-aistudio'
5104
5466
 
5105
5467
  /** Keep in sync with package.json and src/components.js. */
5106
- const PLUGIN_VERSION = '0.2.7'
5468
+ const PLUGIN_VERSION = '0.2.8'
5107
5469
 
5108
5470
  /** Presence marker the live verifier reads. */
5109
5471
  const DEBUG_KEY = '__DSH_AS_AISTUDIO__'
package/src/components.js CHANGED
@@ -26,7 +26,7 @@
26
26
  export const STUDIO_ID = 'dsh-as-aistudio'
27
27
 
28
28
  /** Keep in sync with package.json and src/client.js. */
29
- export const STUDIO_VERSION = '0.2.7'
29
+ export const STUDIO_VERSION = '0.2.8'
30
30
 
31
31
  /**
32
32
  * The AI Studio action strip this studio composes, in the order the user meets
package/src/studio.js CHANGED
@@ -48,7 +48,7 @@ window.__ModuleLoader__.load({
48
48
  const NS = 'dsh-as-aistudio'
49
49
 
50
50
  /** Keep in sync with package.json and src/components.js. */
51
- const PLUGIN_VERSION = '0.2.7'
51
+ const PLUGIN_VERSION = '0.2.8'
52
52
 
53
53
  /** Presence marker the live verifier reads. */
54
54
  const DEBUG_KEY = '__DSH_AS_AISTUDIO__'
@@ -1,6 +1,6 @@
1
1
  // dsh-edit-turn browser half, VENDORED by tools/vendor.mjs - DO NOT EDIT.
2
2
  //
3
- // Source: ../dsh-edit-turn/lib/client.js (v0.2.18, sha256 424621261094f17b)
3
+ // Source: ../dsh-edit-turn/lib/client.js (v0.2.19, sha256 618c7394fb3880c4)
4
4
  // Regenerate with `npm run vendor`; test/vendor-sync.test.js fails when this is stale.
5
5
  //
6
6
  // The component registers its factory with the host module loader; inside this
@@ -18,13 +18,21 @@ export default (require) => {
18
18
 
19
19
  const NS = 'dsh-edit-turn'
20
20
  const ROUTE_PREFIX = '/dsh-edit-turn'
21
+ // One picked file may not exceed this. It mirrors the platform's own default
22
+ // per-image ceiling (dsh-attachment-local's 20 MiB), because a payload above
23
+ // it would be refused by the store anyway - saying so in the picker is
24
+ // kinder than a round trip that fails.
25
+ const MAX_ATTACH_BYTES = 20 * 1024 * 1024
26
+ // Above this a chip keeps its label instead of trying to draw itself: a
27
+ // thumbnail is not worth decoding a 30 MB attachment for.
28
+ const MAX_THUMB_BYTES = 8 * 1024 * 1024
21
29
  // The sibling that owns re-running: it shadows a turn and regenerates it
22
30
  // from the prompt the surface now shows, then replays what followed. The
23
31
  // editor's re-run button is only offered when it is mounted.
24
32
  const RERUN_PREFIX = '/dsh-rerun-turn'
25
33
 
26
34
  /** Keep in sync with package.json and lib/index.js. */
27
- const PLUGIN_VERSION = '0.2.18'
35
+ const PLUGIN_VERSION = '0.2.19'
28
36
 
29
37
  // --- copy -----------------------------------------------------------------
30
38
 
@@ -51,6 +59,8 @@ export default (require) => {
51
59
  'editor.pending': '正在保存…',
52
60
  'editor.warn.attachments': '这条消息包含图片或文件附件,改写会丢弃它们,只保留文字。',
53
61
  'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
62
+ 'editor.attach.add': '添加附件',
63
+ 'editor.attach.remove': '移除这个附件',
54
64
  'confirm.body': '确认后会立即执行:这条消息之后的内容会从模型上下文中移除。原始会话日志不会改写。',
55
65
  'error.invalid': '请求无效,请刷新后重试。',
56
66
  'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
@@ -72,6 +82,11 @@ export default (require) => {
72
82
  'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
73
83
  'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
74
84
  'error.internal': '服务器内部错误,请稍后重试。',
85
+ 'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
86
+ 'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
87
+ 'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
88
+ 'error.attachment-too-large': '这个文件太大,无法作为附件。',
89
+ 'error.too-large': '这次修改太大,服务器没有接收。',
75
90
  }
76
91
 
77
92
  const en = {
@@ -92,6 +107,8 @@ export default (require) => {
92
107
  'editor.pending': 'Working...',
93
108
  'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
94
109
  'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
110
+ 'editor.attach.add': 'Add an attachment',
111
+ 'editor.attach.remove': 'Remove this attachment',
95
112
  'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
96
113
  'error.invalid': 'Invalid request; refresh and try again.',
97
114
  'error.session-not-active': 'This session is not open in DSH; open it first.',
@@ -113,6 +130,11 @@ export default (require) => {
113
130
  'error.already-retired': 'This turn is no longer part of the current context.',
114
131
  'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
115
132
  'error.internal': 'The host hit an internal error; try again shortly.',
133
+ 'error.attachment-refused': 'The attachment could not be stored, so nothing was changed.',
134
+ 'error.attachments-dropped': 'Saved, but the images or files on this message could not be kept: this deployment mounts no attachment store.',
135
+ 'error.attachment-unreadable': 'That attachment could not be read; try another file.',
136
+ 'error.attachment-too-large': 'That file is too large to attach.',
137
+ 'error.too-large': 'That revision was too large for the host to accept.',
116
138
  }
117
139
 
118
140
  // Every error code the host can return travels through here. A code is
@@ -221,6 +243,25 @@ export default (require) => {
221
243
  '.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}',
222
244
  '.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
223
245
  '.dshet-editor textarea:disabled{opacity:.65}',
246
+ // The blocks this message carries: one chip each, drawn from the same
247
+ // tokens as the panel so a skin cannot wash them out. A chip only takes
248
+ // the width it needs; the label is the only part that may shrink, and the
249
+ // remove button never does.
250
+ '.dshet-chips{display:flex;flex-wrap:wrap;gap:6px;margin:8px 14px 0}',
251
+ '.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}',
252
+ '.dshet-thumb{width:26px;height:26px;flex:none;border-radius:6px;object-fit:cover;background:var(--dshet-field)}',
253
+ '.dshet-chip-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
254
+ '.dshet-chip-size{flex:none;color:var(--dshet-ink-dim)}',
255
+ '.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}',
256
+ '.dshet-chip-remove:hover:not(:disabled){background:var(--dshet-hover);color:var(--dsw-alias-state-error-primary,#c93a31)}',
257
+ '.dshet-chip-remove:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:1px}',
258
+ '.dshet-chip-remove:disabled{cursor:default;opacity:.5}',
259
+ '.dshet-attach-row{display:flex;align-items:center;gap:8px;margin:8px 14px 0}',
260
+ '.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}',
261
+ '.dshet-attach:hover:not(:disabled){background:var(--dshet-hover);color:var(--dshet-ink)}',
262
+ '.dshet-attach:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
263
+ '.dshet-attach:disabled{cursor:default;opacity:.5}',
264
+ '.dshet-attach-input{display:none}',
224
265
  '.dshet-note,.dshet-warn,.dshet-error{margin:4px 14px 0;font-size:12px;line-height:19px}',
225
266
  '.dshet-note{color:var(--dshet-ink-dim)}',
226
267
  '.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
@@ -295,6 +336,16 @@ export default (require) => {
295
336
  this.editorRow = null
296
337
  this.editorFocus = false
297
338
  this.editorDraft = ''
339
+ // The block list being edited: what the carrier will be rebuilt from.
340
+ // Every entry is either `{ keep: <index> }` - a block of this message the
341
+ // user left alone - or `{ add: { data, mediaType?, name } }` for
342
+ // something they just picked, with the display fields the chip needs.
343
+ // The list is the ONLY thing that decides what the host writes, which is
344
+ // why a chip the user removes simply leaves it.
345
+ this.editorParts = []
346
+ // Names the added chips, so removing one is a matter of identity rather
347
+ // than of position (a DOM pass may run between two of them).
348
+ this.uploadSeq = 0
298
349
  this.clearTimer = null
299
350
  this.pendingRerun = false
300
351
  this.siblingRerun = 'unknown'
@@ -311,6 +362,7 @@ export default (require) => {
311
362
  repliesByMessage: new Map(),
312
363
  editing: null,
313
364
  draft: '',
365
+ parts: [],
314
366
  confirming: false,
315
367
  pending: false,
316
368
  failure: null,
@@ -318,6 +370,12 @@ export default (require) => {
318
370
  surfaceReady: false,
319
371
  loaded: false,
320
372
  loadError: false,
373
+ // What the host said it can do with a block list (see /state's
374
+ // `capabilities`). False is the safe reading: an older host, a failed
375
+ // load and a deployment without an attachment store all mean "text
376
+ // only", which is what the editor then offers - with the warning that
377
+ // says so - instead of a chip it cannot honour.
378
+ attachmentsOk: false,
321
379
  // Matches the host's default: one click applies. Only an explicit
322
380
  // `confirm: true` in the profile turns the second step back on.
323
381
  confirmStep: false,
@@ -402,6 +460,10 @@ export default (require) => {
402
460
  loaded: true,
403
461
  loadError: false,
404
462
  confirmStep: !(data.config && data.config.confirm === false),
463
+ // What this host can do with blocks, taken from its own answer.
464
+ // An older host says nothing here, which reads as "text only" -
465
+ // the behaviour every release before blocks had.
466
+ attachmentsOk: Boolean(data.capabilities && data.capabilities.attachments === true),
405
467
  })
406
468
  })
407
469
  .catch(() => {
@@ -422,7 +484,10 @@ export default (require) => {
422
484
  const previous = this.editorElement
423
485
  this.load()
424
486
  this.editor = target
425
- if (!same) this.editorDraft = target.text
487
+ if (!same) {
488
+ this.editorDraft = target.text
489
+ this.editorParts = blocksToParts(target, this.sessionId)
490
+ }
426
491
  this.confirming = false
427
492
  this.editorElement = null
428
493
  // The intent to keep the user's caret in this box. It is set when the
@@ -438,7 +503,7 @@ export default (require) => {
438
503
  /* focus is best-effort */
439
504
  }
440
505
  }
441
- this.publish({ editing: target, draft: this.editorDraft, confirming: false, failure: null })
506
+ this.publish({ editing: target, draft: this.editorDraft, parts: this.editorParts.slice(), confirming: false, failure: null })
442
507
  this.probeSiblingRerun()
443
508
  }
444
509
 
@@ -475,15 +540,81 @@ export default (require) => {
475
540
  this.editorFocus = false
476
541
  this.editor = null
477
542
  this.editorDraft = ''
543
+ this.editorParts = []
478
544
  this.confirming = false
479
545
  this.editorElement = null
480
- this.publish({ editing: null, draft: '', confirming: false, failure: null })
546
+ this.publish({ editing: null, draft: '', parts: [], confirming: false, failure: null })
481
547
  }
482
548
 
483
549
  setDraft(value) {
484
550
  this.editorDraft = value
485
551
  }
486
552
 
553
+ /**
554
+ * One chip the user removed leaves the block list, and nothing else.
555
+ *
556
+ * The list IS the edit: a block that is no longer in it is not written
557
+ * when the carrier is rebuilt, so removing a chip is the whole
558
+ * implementation of "this attachment should not be here any more".
559
+ */
560
+ removePart(key) {
561
+ const next = this.editorParts.filter((part) => part.key !== key)
562
+ if (next.length === this.editorParts.length) return
563
+ this.editorParts = next
564
+ this.publish({ parts: next.slice() })
565
+ }
566
+
567
+ /** One payload the user picked joins the block list, at the end. */
568
+ addUpload(upload) {
569
+ this.editorParts = [...this.editorParts, upload]
570
+ this.publish({ parts: this.editorParts.slice() })
571
+ }
572
+
573
+ /**
574
+ * Read picked files and append one chip each.
575
+ *
576
+ * The bytes stay in the browser until the save: the host admits them
577
+ * through the platform's attachment store only when the user commits, so
578
+ * opening the editor and cancelling stores nothing.
579
+ */
580
+ attachFiles(files) {
581
+ let list = []
582
+ try {
583
+ list = files === null || files === undefined ? [] : Array.prototype.slice.call(files)
584
+ } catch {
585
+ list = []
586
+ }
587
+ for (const file of list) {
588
+ if (!file || typeof file !== 'object') continue
589
+ if (typeof file.size === 'number' && file.size > MAX_ATTACH_BYTES) {
590
+ this.notify('attachment-too-large')
591
+ continue
592
+ }
593
+ const name = typeof file.name === 'string' && file.name !== '' ? file.name : null
594
+ const mediaType = typeof file.type === 'string' && file.type !== '' ? file.type : null
595
+ readAsBase64(file, (data) => {
596
+ if (data === null) {
597
+ this.notify('attachment-unreadable')
598
+ return
599
+ }
600
+ this.uploadSeq += 1
601
+ this.addUpload({
602
+ key: `a${this.uploadSeq}`,
603
+ add: {
604
+ data,
605
+ ...(mediaType === null ? {} : { mediaType }),
606
+ ...(name === null ? {} : { name }),
607
+ },
608
+ // The chip's own fields. A picked image can show itself through
609
+ // the very bytes it is carrying - no round trip, no store.
610
+ label: name !== null ? name : mediaType !== null ? mediaType : 'attachment',
611
+ bytes: typeof file.size === 'number' ? file.size : null,
612
+ thumbnail: `data:${mediaType === null ? 'application/octet-stream' : mediaType};base64,${data}`,
613
+ })
614
+ })
615
+ }
616
+ }
617
+
487
618
  review() {
488
619
  this.pendingRerun = false
489
620
  this.runReview()
@@ -524,6 +655,13 @@ export default (require) => {
524
655
  this.publish({ failure: 'empty' })
525
656
  return
526
657
  }
658
+ // What the host will rebuild the carrier from: the blocks still on the
659
+ // list, in order, plus anything picked. Sent only when the host answered
660
+ // that it can carry blocks - a request that names blocks this host would
661
+ // have to drop is a request that lies about what the save did.
662
+ const parts = target.mode === 'prompt' && this.view.attachmentsOk === true
663
+ ? this.editorParts.map((part) => (part.keep !== undefined ? { keep: part.keep } : { add: part.add }))
664
+ : undefined
527
665
  this.publish({ pending: true, failure: null })
528
666
  try {
529
667
  const res = await fetch(`${ROUTE_PREFIX}/apply`, {
@@ -534,6 +672,7 @@ export default (require) => {
534
672
  seq: target.seq,
535
673
  messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
536
674
  text,
675
+ ...(parts === undefined ? {} : { parts }),
537
676
  }),
538
677
  })
539
678
  const data = await res.json().catch(() => ({}))
@@ -564,6 +703,11 @@ export default (require) => {
564
703
  messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
565
704
  text,
566
705
  attachments: typeof target.attachments === 'number' ? target.attachments : 0,
706
+ // The host answers with what the carrier now carries, described
707
+ // the way /state describes it. Adopted for the row drawn before
708
+ // the refresh lands: without it a second save could not know
709
+ // which blocks are still there and would rewrite text-only.
710
+ blocks: Array.isArray(data.blocks) ? data.blocks : [],
567
711
  })
568
712
  }
569
713
  // A reply edit shadows its own reply too, so it leaves the editable
@@ -576,6 +720,7 @@ export default (require) => {
576
720
  this.animateOnce = true
577
721
  this.editor = null
578
722
  this.editorDraft = ''
723
+ this.editorParts = []
579
724
  this.editorElement = null
580
725
  this.editorBox = null
581
726
  this.editorRow = null
@@ -589,6 +734,7 @@ export default (require) => {
589
734
  revisions,
590
735
  editable,
591
736
  replies,
737
+ parts: [],
592
738
  failure: null,
593
739
  })
594
740
  // `unchanged` means the host wrote NOTHING: the draft is (verbatim)
@@ -610,6 +756,11 @@ export default (require) => {
610
756
  // failed second half has to be reported somewhere that survives that.
611
757
  if (data.kind === 'reply' && data.applied === false) this.notify('reply')
612
758
  if (data.kind !== 'reply' && data.applied === false) this.notify('prompt')
759
+ // The save landed, but it left non-text blocks behind (the host says
760
+ // so): the editor that could have warned is gone by now, so this is
761
+ // the only place the user can still be told (see applyEdit's
762
+ // `dropped`).
763
+ if (data.kind !== 'reply' && data.applied === true && data.dropped === true) this.notify('attachments-dropped')
613
764
  }
614
765
  // Save first, re-run second - always: the sibling re-runs the turn
615
766
  // from the prompt the surface NOW shows, so the revised wording has
@@ -717,6 +868,10 @@ export default (require) => {
717
868
  messageId: entry.messageId,
718
869
  text: typeof entry.text === 'string' ? entry.text : '',
719
870
  attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
871
+ // The blocks this message carries, as /state described them. They seed
872
+ // the editor's chips; a host that does not describe them (or an
873
+ // optimistic entry that predates its refresh) simply has none.
874
+ blocks: Array.isArray(entry.blocks) ? entry.blocks : [],
720
875
  }
721
876
  }
722
877
  return null
@@ -1678,6 +1833,94 @@ export default (require) => {
1678
1833
  // injecting a second controlled tree into it would fight its reconciler.
1679
1834
  // The draft is mirrored into the controller on every input event, so a
1680
1835
  // host re-render that wipes the textarea cannot lose what was typed.
1836
+ // --- block chips ----------------------------------------------------------
1837
+ //
1838
+ // A message's non-text blocks, as the editor shows them: one chip per block,
1839
+ // each with what the block itself reported about itself (a name, a size, and
1840
+ // whether its bytes are reachable) and a remove button. Nothing here reads a
1841
+ // block TYPE: a chip is drawn from the fields a block carries, so a block
1842
+ // kind the platform adds later shows up with its own name and needs no
1843
+ // change in this file.
1844
+
1845
+ /** One chip's display fields, taken from whatever the block said about itself. */
1846
+ function chipOf(block) {
1847
+ const name = typeof block.name === 'string' && block.name !== '' ? block.name : null
1848
+ const mediaType = typeof block.mediaType === 'string' && block.mediaType !== '' ? block.mediaType : null
1849
+ const type = typeof block.type === 'string' && block.type !== '' ? block.type : 'block'
1850
+ const bytes = typeof block.bytes === 'number' ? block.bytes : null
1851
+ return { label: name !== null ? name : mediaType !== null ? mediaType : type, bytes }
1852
+ }
1853
+
1854
+ /**
1855
+ * The block list an editor starts from: every block of the message, kept.
1856
+ *
1857
+ * A reply starts from none. What a reply carries beside its text - tool
1858
+ * calls, reasoning - cannot survive a replacement (their results no longer
1859
+ * hold), so offering a chip for them would promise something the edit cannot
1860
+ * keep; the editor warns about them instead.
1861
+ */
1862
+ function blocksToParts(target, sessionId) {
1863
+ if (!target || target.mode !== 'prompt') return []
1864
+ const blocks = Array.isArray(target.blocks) ? target.blocks : []
1865
+ const parts = []
1866
+ for (const block of blocks) {
1867
+ if (!block || typeof block.index !== 'number') continue
1868
+ const chip = chipOf(block)
1869
+ parts.push({
1870
+ keep: block.index,
1871
+ key: `k${block.index}`,
1872
+ label: chip.label,
1873
+ bytes: chip.bytes,
1874
+ thumbnail: block.preview === true ? previewUrl(sessionId, target.seq, block.index) : null,
1875
+ })
1876
+ }
1877
+ return parts
1878
+ }
1879
+
1880
+ /** Where one stored block's bytes are read from, for its thumbnail. */
1881
+ function previewUrl(sessionId, seq, index) {
1882
+ return `${ROUTE_PREFIX}/attachment?sessionId=${encodeURIComponent(sessionId)}&seq=${seq}&index=${index}`
1883
+ }
1884
+
1885
+ /** A byte count a human reads at a glance. */
1886
+ function formatBytes(bytes) {
1887
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return ''
1888
+ if (bytes < 1024) return `${bytes} B`
1889
+ const units = ['KB', 'MB', 'GB']
1890
+ let value = bytes / 1024
1891
+ let unit = 0
1892
+ while (value >= 1024 && unit < units.length - 1) {
1893
+ value /= 1024
1894
+ unit += 1
1895
+ }
1896
+ return `${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10} ${units[unit]}`
1897
+ }
1898
+
1899
+ /**
1900
+ * One picked file's bytes, base64, without the Data URL prefix.
1901
+ *
1902
+ * Read here rather than at save time so a save is one request: the payload
1903
+ * travels with the revision the user committed to.
1904
+ */
1905
+ function readAsBase64(file, done) {
1906
+ if (typeof FileReader !== 'function') {
1907
+ done(null)
1908
+ return
1909
+ }
1910
+ try {
1911
+ const reader = new FileReader()
1912
+ reader.onload = () => {
1913
+ const result = typeof reader.result === 'string' ? reader.result : ''
1914
+ const comma = result.indexOf(',')
1915
+ done(comma === -1 ? null : result.slice(comma + 1))
1916
+ }
1917
+ reader.onerror = () => done(null)
1918
+ reader.readAsDataURL(file)
1919
+ } catch {
1920
+ done(null)
1921
+ }
1922
+ }
1923
+
1681
1924
  // The re-render marker for the in-place editor.
1682
1925
  //
1683
1926
  // Every field `renderEditor` reads belongs here. A marker that only tracks
@@ -1694,6 +1937,13 @@ export default (require) => {
1694
1937
  // The re-run button appears when the sibling probe lands; without it in
1695
1938
  // the marker the editor would never be rebuilt to show the button.
1696
1939
  view.siblingRerun === 'present' ? 'r' : 'n',
1940
+ // Whether chips are drawn at all: a host that cannot admit blocks asks
1941
+ // for the warning instead, and the box has to change with that answer.
1942
+ view.attachmentsOk === true ? 'A' : 'a',
1943
+ // The block list itself. Removing a chip and adding one both change what
1944
+ // saving would write, so both have to redraw the strip - and the chip
1945
+ // keys are enough to tell one list from another.
1946
+ view.parts.map((part) => part.key).join('.'),
1697
1947
  ].join('|')
1698
1948
  }
1699
1949
 
@@ -1712,6 +1962,100 @@ export default (require) => {
1712
1962
  }
1713
1963
  }
1714
1964
 
1965
+ /**
1966
+ * The chips: one per block on the block list.
1967
+ *
1968
+ * A chip IS the block's presence in the edit. Removing it takes the block
1969
+ * off the list, and a block that is not on the list is not written when the
1970
+ * carrier is rebuilt - the whole mechanism, and the same one for a picture,
1971
+ * a file and whatever the platform carries next. A chip draws itself from
1972
+ * the bytes the block already has, and gives up on its thumbnail when the
1973
+ * browser says they are not a picture: no type is consulted either way.
1974
+ */
1975
+ function editorChips(controller, view, t) {
1976
+ const strip = document.createElement('div')
1977
+ strip.className = 'dshet-chips'
1978
+ for (const part of view.parts) {
1979
+ const chip = document.createElement('div')
1980
+ chip.className = 'dshet-chip'
1981
+ chip.dataset.dshetChip = part.key
1982
+ const drawable = typeof part.thumbnail === 'string' && part.thumbnail !== '' &&
1983
+ (typeof part.bytes !== 'number' || part.bytes <= MAX_THUMB_BYTES)
1984
+ if (drawable) {
1985
+ const image = document.createElement('img')
1986
+ image.className = 'dshet-thumb'
1987
+ image.alt = ''
1988
+ image.src = part.thumbnail
1989
+ image.addEventListener('error', () => {
1990
+ const parent = image.parentElement
1991
+ if (typeof image.remove === 'function') image.remove()
1992
+ else if (parent !== null && parent !== undefined && typeof parent.removeChild === 'function') parent.removeChild(image)
1993
+ })
1994
+ chip.appendChild(image)
1995
+ }
1996
+ const label = document.createElement('span')
1997
+ label.className = 'dshet-chip-label'
1998
+ label.textContent = part.label
1999
+ chip.appendChild(label)
2000
+ const size = formatBytes(part.bytes)
2001
+ if (size !== '') {
2002
+ const measured = document.createElement('span')
2003
+ measured.className = 'dshet-chip-size'
2004
+ measured.textContent = size
2005
+ chip.appendChild(measured)
2006
+ }
2007
+ const remove = document.createElement('button')
2008
+ remove.type = 'button'
2009
+ remove.className = 'dshet-chip-remove'
2010
+ remove.textContent = '×'
2011
+ remove.title = t('editor.attach.remove')
2012
+ remove.setAttribute('aria-label', t('editor.attach.remove'))
2013
+ remove.disabled = view.pending
2014
+ pressable(remove, () => controller.removePart(part.key))
2015
+ chip.appendChild(remove)
2016
+ strip.appendChild(chip)
2017
+ }
2018
+ return strip
2019
+ }
2020
+
2021
+ /**
2022
+ * The add/replace entry: a picker that puts a block on the list.
2023
+ *
2024
+ * Drawn only when the host answered that it can admit a block. A deployment
2025
+ * with no attachment store gets no picker, because a picker that cannot be
2026
+ * honoured is worse than none - the warning below says what a save will
2027
+ * drop instead.
2028
+ */
2029
+ function attachEntry(controller, view, t) {
2030
+ const row = document.createElement('div')
2031
+ row.className = 'dshet-attach-row'
2032
+ const picker = document.createElement('input')
2033
+ picker.type = 'file'
2034
+ picker.multiple = true
2035
+ picker.className = 'dshet-attach-input'
2036
+ picker.style.display = 'none'
2037
+ picker.addEventListener('change', () => {
2038
+ controller.attachFiles(picker.files)
2039
+ // Cleared so the same file can be picked again after a removal.
2040
+ try {
2041
+ picker.value = ''
2042
+ } catch {
2043
+ /* not every input lets this be written */
2044
+ }
2045
+ })
2046
+ const add = document.createElement('button')
2047
+ add.type = 'button'
2048
+ add.className = 'dshet-attach'
2049
+ add.textContent = t('editor.attach.add')
2050
+ add.disabled = view.pending
2051
+ pressable(add, () => {
2052
+ if (typeof picker.click === 'function') picker.click()
2053
+ })
2054
+ row.appendChild(picker)
2055
+ row.appendChild(add)
2056
+ return row
2057
+ }
2058
+
1715
2059
  function renderEditor(row, controller, view, t) {
1716
2060
  const target = view.editing
1717
2061
  const texts = editorTexts(t, target)
@@ -1784,6 +2128,18 @@ export default (require) => {
1784
2128
  })
1785
2129
  box.appendChild(area)
1786
2130
 
2131
+ // The blocks this message carries, each one a chip the user can remove,
2132
+ // and the entry that adds another. Drawn only when the host answered that
2133
+ // it can admit what a chip promises - otherwise the warning below states
2134
+ // what a save would drop, which is exactly what this editor did before
2135
+ // blocks travelled at all. A reply never gets chips: what it carries
2136
+ // beside its text cannot survive a replacement.
2137
+ const chips = target.mode === 'prompt' && view.attachmentsOk === true
2138
+ if (chips) {
2139
+ if (view.parts.length > 0) box.appendChild(editorChips(controller, view, t))
2140
+ box.appendChild(attachEntry(controller, view, t))
2141
+ }
2142
+
1787
2143
  // Only what the user has to know right now: what the replacement will
1788
2144
  // discard, and what went wrong. The running explanation lives in the
1789
2145
  // README and in the buttons' tooltips, the way a normal input box does it.
@@ -1793,7 +2149,9 @@ export default (require) => {
1793
2149
  note.textContent = t('confirm.body')
1794
2150
  box.appendChild(note)
1795
2151
  }
1796
- if (target.attachments > 0) {
2152
+ // Shown when saving really would drop something: a reply's tool calls
2153
+ // always, and a prompt's blocks when this host cannot carry them.
2154
+ if (target.attachments > 0 && !chips) {
1797
2155
  const warn = document.createElement('p')
1798
2156
  warn.className = 'dshet-warn'
1799
2157
  warn.textContent = texts.warn
@@ -2019,6 +2377,10 @@ export default (require) => {
2019
2377
  messageId: entry.messageId,
2020
2378
  text: typeof entry.text === 'string' ? entry.text : '',
2021
2379
  attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
2380
+ // A reply edits text only on purpose: the tool calls and reasoning it
2381
+ // carries stop being true the moment it is replaced, so they are not
2382
+ // offered as chips (the editor warns about them instead).
2383
+ blocks: [],
2022
2384
  })
2023
2385
  }
2024
2386
  // The config object is always an object: this runtime's `jsx` reads