dsh-as-aistudio 0.2.6 → 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.6",
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.17",
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.17'
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': '这个会话当前未激活,请先打开该会话再编辑。',
@@ -73,12 +83,18 @@ window.__ModuleLoader__.load({
73
83
  'error.prompt': '已回退,但改后的内容没能写进上下文。请重试。',
74
84
  'error.reply': '已回退,但替换没有落地。请重试。',
75
85
  'error.empty': '改写后的内容不能为空。',
86
+ 'error.unchanged': '内容没有变化,未做任何修改。',
76
87
  'error.rerun-nothing': '已保存,但这一轮没有可重跑的作答。',
77
88
  'error.rerun-unavailable': '已保存,但重跑没有开始,请稍后重试。',
78
89
  'error.not-rerunnable': '这一轮没有可重跑的作答。',
79
90
  'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
80
91
  'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
81
92
  'error.internal': '服务器内部错误,请稍后重试。',
93
+ 'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
94
+ 'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
95
+ 'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
96
+ 'error.attachment-too-large': '这个文件太大,无法作为附件。',
97
+ 'error.too-large': '这次修改太大,服务器没有接收。',
82
98
  }
83
99
 
84
100
  const en = {
@@ -99,6 +115,8 @@ window.__ModuleLoader__.load({
99
115
  'editor.pending': 'Working...',
100
116
  'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
101
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',
102
120
  'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
103
121
  'error.invalid': 'Invalid request; refresh and try again.',
104
122
  'error.session-not-active': 'This session is not open in DSH; open it first.',
@@ -113,12 +131,18 @@ window.__ModuleLoader__.load({
113
131
  'error.prompt': 'Rolled back, but the re-run did not start. Send the revised text manually.',
114
132
  'error.reply': 'Rolled back, but the replacement did not land. Try again.',
115
133
  'error.empty': 'The revised message cannot be empty.',
134
+ 'error.unchanged': 'Nothing changed, so no edit was made.',
116
135
  'error.rerun-nothing': 'Saved, but this turn has no reply that can be re-run.',
117
136
  'error.rerun-unavailable': 'Saved, but the re-run did not start; try again.',
118
137
  'error.not-rerunnable': 'This turn has no reply that can be re-run.',
119
138
  'error.already-retired': 'This turn is no longer part of the current context.',
120
139
  'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
121
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.',
122
146
  }
123
147
 
124
148
  // Every error code the host can return travels through here. A code is
@@ -227,6 +251,25 @@ window.__ModuleLoader__.load({
227
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}',
228
252
  '.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
229
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}',
230
273
  '.dshet-note,.dshet-warn,.dshet-error{margin:4px 14px 0;font-size:12px;line-height:19px}',
231
274
  '.dshet-note{color:var(--dshet-ink-dim)}',
232
275
  '.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
@@ -301,6 +344,16 @@ window.__ModuleLoader__.load({
301
344
  this.editorRow = null
302
345
  this.editorFocus = false
303
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
304
357
  this.clearTimer = null
305
358
  this.pendingRerun = false
306
359
  this.siblingRerun = 'unknown'
@@ -317,6 +370,7 @@ window.__ModuleLoader__.load({
317
370
  repliesByMessage: new Map(),
318
371
  editing: null,
319
372
  draft: '',
373
+ parts: [],
320
374
  confirming: false,
321
375
  pending: false,
322
376
  failure: null,
@@ -324,6 +378,12 @@ window.__ModuleLoader__.load({
324
378
  surfaceReady: false,
325
379
  loaded: false,
326
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,
327
387
  // Matches the host's default: one click applies. Only an explicit
328
388
  // `confirm: true` in the profile turns the second step back on.
329
389
  confirmStep: false,
@@ -408,6 +468,10 @@ window.__ModuleLoader__.load({
408
468
  loaded: true,
409
469
  loadError: false,
410
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),
411
475
  })
412
476
  })
413
477
  .catch(() => {
@@ -428,7 +492,10 @@ window.__ModuleLoader__.load({
428
492
  const previous = this.editorElement
429
493
  this.load()
430
494
  this.editor = target
431
- if (!same) this.editorDraft = target.text
495
+ if (!same) {
496
+ this.editorDraft = target.text
497
+ this.editorParts = blocksToParts(target, this.sessionId)
498
+ }
432
499
  this.confirming = false
433
500
  this.editorElement = null
434
501
  // The intent to keep the user's caret in this box. It is set when the
@@ -444,7 +511,7 @@ window.__ModuleLoader__.load({
444
511
  /* focus is best-effort */
445
512
  }
446
513
  }
447
- 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 })
448
515
  this.probeSiblingRerun()
449
516
  }
450
517
 
@@ -481,15 +548,81 @@ window.__ModuleLoader__.load({
481
548
  this.editorFocus = false
482
549
  this.editor = null
483
550
  this.editorDraft = ''
551
+ this.editorParts = []
484
552
  this.confirming = false
485
553
  this.editorElement = null
486
- this.publish({ editing: null, draft: '', confirming: false, failure: null })
554
+ this.publish({ editing: null, draft: '', parts: [], confirming: false, failure: null })
487
555
  }
488
556
 
489
557
  setDraft(value) {
490
558
  this.editorDraft = value
491
559
  }
492
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
+
493
626
  review() {
494
627
  this.pendingRerun = false
495
628
  this.runReview()
@@ -530,6 +663,13 @@ window.__ModuleLoader__.load({
530
663
  this.publish({ failure: 'empty' })
531
664
  return
532
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
533
673
  this.publish({ pending: true, failure: null })
534
674
  try {
535
675
  const res = await fetch(`${ROUTE_PREFIX}/apply`, {
@@ -540,6 +680,7 @@ window.__ModuleLoader__.load({
540
680
  seq: target.seq,
541
681
  messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
542
682
  text,
683
+ ...(parts === undefined ? {} : { parts }),
543
684
  }),
544
685
  })
545
686
  const data = await res.json().catch(() => ({}))
@@ -570,6 +711,11 @@ window.__ModuleLoader__.load({
570
711
  messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
571
712
  text,
572
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 : [],
573
719
  })
574
720
  }
575
721
  // A reply edit shadows its own reply too, so it leaves the editable
@@ -582,6 +728,7 @@ window.__ModuleLoader__.load({
582
728
  this.animateOnce = true
583
729
  this.editor = null
584
730
  this.editorDraft = ''
731
+ this.editorParts = []
585
732
  this.editorElement = null
586
733
  this.editorBox = null
587
734
  this.editorRow = null
@@ -595,12 +742,34 @@ window.__ModuleLoader__.load({
595
742
  revisions,
596
743
  editable,
597
744
  replies,
745
+ parts: [],
598
746
  failure: null,
599
747
  })
600
- // The rollback has landed, so the editor is gone with its row: a
601
- // failed second half has to be reported somewhere that survives that.
602
- if (data.kind === 'reply' && data.applied === false) this.notify('reply')
603
- if (data.kind !== 'reply' && data.applied === false) this.notify('prompt')
748
+ // `unchanged` means the host wrote NOTHING: the draft is (verbatim)
749
+ // the text the message already carries. There is no failure to report -
750
+ // the edit was simply not an edit - so it is noticed as such, and
751
+ // nothing else about this pass changes: no row was shadowed, so none is
752
+ // hidden, and the message stays on screen with the pencil it had.
753
+ //
754
+ // The re-run intent survives it. "Re-run" saves first only because the
755
+ // sibling regenerates the turn from the prompt the surface shows; a
756
+ // user who pressed it wants that turn regenerated whether or not the
757
+ // wording moved, so the chain below is NOT gated on a write having
758
+ // happened. (The decision itself is the host's - the text is parsed
759
+ // there, once - and this half only reads the flag it sends back.)
760
+ if (data.unchanged === true) {
761
+ if (!rerunIntent) this.notify('unchanged')
762
+ } else {
763
+ // The rollback has landed, so the editor is gone with its row: a
764
+ // failed second half has to be reported somewhere that survives that.
765
+ if (data.kind === 'reply' && data.applied === false) this.notify('reply')
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')
772
+ }
604
773
  // Save first, re-run second - always: the sibling re-runs the turn
605
774
  // from the prompt the surface NOW shows, so the revised wording has
606
775
  // to be on the surface before it is asked to regenerate. Not
@@ -707,6 +876,10 @@ window.__ModuleLoader__.load({
707
876
  messageId: entry.messageId,
708
877
  text: typeof entry.text === 'string' ? entry.text : '',
709
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 : [],
710
883
  }
711
884
  }
712
885
  return null
@@ -1668,6 +1841,94 @@ window.__ModuleLoader__.load({
1668
1841
  // injecting a second controlled tree into it would fight its reconciler.
1669
1842
  // The draft is mirrored into the controller on every input event, so a
1670
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
+
1671
1932
  // The re-render marker for the in-place editor.
1672
1933
  //
1673
1934
  // Every field `renderEditor` reads belongs here. A marker that only tracks
@@ -1684,6 +1945,13 @@ window.__ModuleLoader__.load({
1684
1945
  // The re-run button appears when the sibling probe lands; without it in
1685
1946
  // the marker the editor would never be rebuilt to show the button.
1686
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('.'),
1687
1955
  ].join('|')
1688
1956
  }
1689
1957
 
@@ -1702,6 +1970,100 @@ window.__ModuleLoader__.load({
1702
1970
  }
1703
1971
  }
1704
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
+
1705
2067
  function renderEditor(row, controller, view, t) {
1706
2068
  const target = view.editing
1707
2069
  const texts = editorTexts(t, target)
@@ -1774,6 +2136,18 @@ window.__ModuleLoader__.load({
1774
2136
  })
1775
2137
  box.appendChild(area)
1776
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
+
1777
2151
  // Only what the user has to know right now: what the replacement will
1778
2152
  // discard, and what went wrong. The running explanation lives in the
1779
2153
  // README and in the buttons' tooltips, the way a normal input box does it.
@@ -1783,7 +2157,9 @@ window.__ModuleLoader__.load({
1783
2157
  note.textContent = t('confirm.body')
1784
2158
  box.appendChild(note)
1785
2159
  }
1786
- 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) {
1787
2163
  const warn = document.createElement('p')
1788
2164
  warn.className = 'dshet-warn'
1789
2165
  warn.textContent = texts.warn
@@ -2009,6 +2385,10 @@ window.__ModuleLoader__.load({
2009
2385
  messageId: entry.messageId,
2010
2386
  text: typeof entry.text === 'string' ? entry.text : '',
2011
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: [],
2012
2392
  })
2013
2393
  }
2014
2394
  // The config object is always an object: this runtime's `jsx` reads
@@ -5085,7 +5465,7 @@ window.__ModuleLoader__.load({
5085
5465
  const NS = 'dsh-as-aistudio'
5086
5466
 
5087
5467
  /** Keep in sync with package.json and src/components.js. */
5088
- const PLUGIN_VERSION = '0.2.6'
5468
+ const PLUGIN_VERSION = '0.2.8'
5089
5469
 
5090
5470
  /** Presence marker the live verifier reads. */
5091
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.6'
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.6'
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.17, sha256 4719c94b7e1d9104)
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.17'
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': '这个会话当前未激活,请先打开该会话再编辑。',
@@ -65,12 +75,18 @@ export default (require) => {
65
75
  'error.prompt': '已回退,但改后的内容没能写进上下文。请重试。',
66
76
  'error.reply': '已回退,但替换没有落地。请重试。',
67
77
  'error.empty': '改写后的内容不能为空。',
78
+ 'error.unchanged': '内容没有变化,未做任何修改。',
68
79
  'error.rerun-nothing': '已保存,但这一轮没有可重跑的作答。',
69
80
  'error.rerun-unavailable': '已保存,但重跑没有开始,请稍后重试。',
70
81
  'error.not-rerunnable': '这一轮没有可重跑的作答。',
71
82
  'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
72
83
  'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
73
84
  'error.internal': '服务器内部错误,请稍后重试。',
85
+ 'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
86
+ 'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
87
+ 'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
88
+ 'error.attachment-too-large': '这个文件太大,无法作为附件。',
89
+ 'error.too-large': '这次修改太大,服务器没有接收。',
74
90
  }
75
91
 
76
92
  const en = {
@@ -91,6 +107,8 @@ export default (require) => {
91
107
  'editor.pending': 'Working...',
92
108
  'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
93
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',
94
112
  'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
95
113
  'error.invalid': 'Invalid request; refresh and try again.',
96
114
  'error.session-not-active': 'This session is not open in DSH; open it first.',
@@ -105,12 +123,18 @@ export default (require) => {
105
123
  'error.prompt': 'Rolled back, but the re-run did not start. Send the revised text manually.',
106
124
  'error.reply': 'Rolled back, but the replacement did not land. Try again.',
107
125
  'error.empty': 'The revised message cannot be empty.',
126
+ 'error.unchanged': 'Nothing changed, so no edit was made.',
108
127
  'error.rerun-nothing': 'Saved, but this turn has no reply that can be re-run.',
109
128
  'error.rerun-unavailable': 'Saved, but the re-run did not start; try again.',
110
129
  'error.not-rerunnable': 'This turn has no reply that can be re-run.',
111
130
  'error.already-retired': 'This turn is no longer part of the current context.',
112
131
  'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
113
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.',
114
138
  }
115
139
 
116
140
  // Every error code the host can return travels through here. A code is
@@ -219,6 +243,25 @@ export default (require) => {
219
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}',
220
244
  '.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
221
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}',
222
265
  '.dshet-note,.dshet-warn,.dshet-error{margin:4px 14px 0;font-size:12px;line-height:19px}',
223
266
  '.dshet-note{color:var(--dshet-ink-dim)}',
224
267
  '.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
@@ -293,6 +336,16 @@ export default (require) => {
293
336
  this.editorRow = null
294
337
  this.editorFocus = false
295
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
296
349
  this.clearTimer = null
297
350
  this.pendingRerun = false
298
351
  this.siblingRerun = 'unknown'
@@ -309,6 +362,7 @@ export default (require) => {
309
362
  repliesByMessage: new Map(),
310
363
  editing: null,
311
364
  draft: '',
365
+ parts: [],
312
366
  confirming: false,
313
367
  pending: false,
314
368
  failure: null,
@@ -316,6 +370,12 @@ export default (require) => {
316
370
  surfaceReady: false,
317
371
  loaded: false,
318
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,
319
379
  // Matches the host's default: one click applies. Only an explicit
320
380
  // `confirm: true` in the profile turns the second step back on.
321
381
  confirmStep: false,
@@ -400,6 +460,10 @@ export default (require) => {
400
460
  loaded: true,
401
461
  loadError: false,
402
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),
403
467
  })
404
468
  })
405
469
  .catch(() => {
@@ -420,7 +484,10 @@ export default (require) => {
420
484
  const previous = this.editorElement
421
485
  this.load()
422
486
  this.editor = target
423
- if (!same) this.editorDraft = target.text
487
+ if (!same) {
488
+ this.editorDraft = target.text
489
+ this.editorParts = blocksToParts(target, this.sessionId)
490
+ }
424
491
  this.confirming = false
425
492
  this.editorElement = null
426
493
  // The intent to keep the user's caret in this box. It is set when the
@@ -436,7 +503,7 @@ export default (require) => {
436
503
  /* focus is best-effort */
437
504
  }
438
505
  }
439
- 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 })
440
507
  this.probeSiblingRerun()
441
508
  }
442
509
 
@@ -473,15 +540,81 @@ export default (require) => {
473
540
  this.editorFocus = false
474
541
  this.editor = null
475
542
  this.editorDraft = ''
543
+ this.editorParts = []
476
544
  this.confirming = false
477
545
  this.editorElement = null
478
- this.publish({ editing: null, draft: '', confirming: false, failure: null })
546
+ this.publish({ editing: null, draft: '', parts: [], confirming: false, failure: null })
479
547
  }
480
548
 
481
549
  setDraft(value) {
482
550
  this.editorDraft = value
483
551
  }
484
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
+
485
618
  review() {
486
619
  this.pendingRerun = false
487
620
  this.runReview()
@@ -522,6 +655,13 @@ export default (require) => {
522
655
  this.publish({ failure: 'empty' })
523
656
  return
524
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
525
665
  this.publish({ pending: true, failure: null })
526
666
  try {
527
667
  const res = await fetch(`${ROUTE_PREFIX}/apply`, {
@@ -532,6 +672,7 @@ export default (require) => {
532
672
  seq: target.seq,
533
673
  messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
534
674
  text,
675
+ ...(parts === undefined ? {} : { parts }),
535
676
  }),
536
677
  })
537
678
  const data = await res.json().catch(() => ({}))
@@ -562,6 +703,11 @@ export default (require) => {
562
703
  messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
563
704
  text,
564
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 : [],
565
711
  })
566
712
  }
567
713
  // A reply edit shadows its own reply too, so it leaves the editable
@@ -574,6 +720,7 @@ export default (require) => {
574
720
  this.animateOnce = true
575
721
  this.editor = null
576
722
  this.editorDraft = ''
723
+ this.editorParts = []
577
724
  this.editorElement = null
578
725
  this.editorBox = null
579
726
  this.editorRow = null
@@ -587,12 +734,34 @@ export default (require) => {
587
734
  revisions,
588
735
  editable,
589
736
  replies,
737
+ parts: [],
590
738
  failure: null,
591
739
  })
592
- // The rollback has landed, so the editor is gone with its row: a
593
- // failed second half has to be reported somewhere that survives that.
594
- if (data.kind === 'reply' && data.applied === false) this.notify('reply')
595
- if (data.kind !== 'reply' && data.applied === false) this.notify('prompt')
740
+ // `unchanged` means the host wrote NOTHING: the draft is (verbatim)
741
+ // the text the message already carries. There is no failure to report -
742
+ // the edit was simply not an edit - so it is noticed as such, and
743
+ // nothing else about this pass changes: no row was shadowed, so none is
744
+ // hidden, and the message stays on screen with the pencil it had.
745
+ //
746
+ // The re-run intent survives it. "Re-run" saves first only because the
747
+ // sibling regenerates the turn from the prompt the surface shows; a
748
+ // user who pressed it wants that turn regenerated whether or not the
749
+ // wording moved, so the chain below is NOT gated on a write having
750
+ // happened. (The decision itself is the host's - the text is parsed
751
+ // there, once - and this half only reads the flag it sends back.)
752
+ if (data.unchanged === true) {
753
+ if (!rerunIntent) this.notify('unchanged')
754
+ } else {
755
+ // The rollback has landed, so the editor is gone with its row: a
756
+ // failed second half has to be reported somewhere that survives that.
757
+ if (data.kind === 'reply' && data.applied === false) this.notify('reply')
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')
764
+ }
596
765
  // Save first, re-run second - always: the sibling re-runs the turn
597
766
  // from the prompt the surface NOW shows, so the revised wording has
598
767
  // to be on the surface before it is asked to regenerate. Not
@@ -699,6 +868,10 @@ export default (require) => {
699
868
  messageId: entry.messageId,
700
869
  text: typeof entry.text === 'string' ? entry.text : '',
701
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 : [],
702
875
  }
703
876
  }
704
877
  return null
@@ -1660,6 +1833,94 @@ export default (require) => {
1660
1833
  // injecting a second controlled tree into it would fight its reconciler.
1661
1834
  // The draft is mirrored into the controller on every input event, so a
1662
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
+
1663
1924
  // The re-render marker for the in-place editor.
1664
1925
  //
1665
1926
  // Every field `renderEditor` reads belongs here. A marker that only tracks
@@ -1676,6 +1937,13 @@ export default (require) => {
1676
1937
  // The re-run button appears when the sibling probe lands; without it in
1677
1938
  // the marker the editor would never be rebuilt to show the button.
1678
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('.'),
1679
1947
  ].join('|')
1680
1948
  }
1681
1949
 
@@ -1694,6 +1962,100 @@ export default (require) => {
1694
1962
  }
1695
1963
  }
1696
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
+
1697
2059
  function renderEditor(row, controller, view, t) {
1698
2060
  const target = view.editing
1699
2061
  const texts = editorTexts(t, target)
@@ -1766,6 +2128,18 @@ export default (require) => {
1766
2128
  })
1767
2129
  box.appendChild(area)
1768
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
+
1769
2143
  // Only what the user has to know right now: what the replacement will
1770
2144
  // discard, and what went wrong. The running explanation lives in the
1771
2145
  // README and in the buttons' tooltips, the way a normal input box does it.
@@ -1775,7 +2149,9 @@ export default (require) => {
1775
2149
  note.textContent = t('confirm.body')
1776
2150
  box.appendChild(note)
1777
2151
  }
1778
- 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) {
1779
2155
  const warn = document.createElement('p')
1780
2156
  warn.className = 'dshet-warn'
1781
2157
  warn.textContent = texts.warn
@@ -2001,6 +2377,10 @@ export default (require) => {
2001
2377
  messageId: entry.messageId,
2002
2378
  text: typeof entry.text === 'string' ? entry.text : '',
2003
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: [],
2004
2384
  })
2005
2385
  }
2006
2386
  // The config object is always an object: this runtime's `jsx` reads