|
67 | 67 |
|
68 | 68 | #attachImg .ci { width: 14px; height: 14px; display: block; } |
69 | 69 | #attachImg.atcap { opacity: .45; } |
| 70 | + |
| 71 | + /* An attachment problem, said where it happened. Sits under the chips so it reads as belonging to |
| 72 | + the thing that failed, and takes no space when there is nothing to say. */ |
| 73 | + #imgnote { |
| 74 | + font-size: 11.5px; line-height: 1.45; padding: 5px 8px; margin: 2px 0 0; |
| 75 | + border-radius: 5px; color: var(--vscode-inputValidation-warningForeground, var(--vscode-foreground)); |
| 76 | + background: var(--vscode-inputValidation-warningBackground, rgba(224,160,48,.14)); |
| 77 | + border: 1px solid var(--vscode-inputValidation-warningBorder, rgba(224,160,48,.45)); |
| 78 | + } |
| 79 | + #imgnote[hidden] { display: none; } |
| 80 | + |
| 81 | + /* The placeholder that stands in while a big screenshot is being decoded and resized. */ |
| 82 | + .chip.imgchip.loading { opacity: .8; } |
| 83 | + .imgskel { |
| 84 | + width: 28px; height: 28px; border-radius: 3px; |
| 85 | + background: linear-gradient(90deg, rgba(127,127,127,.14) 25%, rgba(127,127,127,.30) 37%, rgba(127,127,127,.14) 63%); |
| 86 | + background-size: 300% 100%; animation: imgskel 1.1s ease-in-out infinite; |
| 87 | + } |
| 88 | + @keyframes imgskel { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } } |
| 89 | + @media (prefers-reduced-motion: reduce) { .imgskel { animation: none; } } |
| 90 | + |
| 91 | + /* What it will cost. Quiet, but present — this product bills credits per turn and an image is |
| 92 | + not a rounding error. */ |
| 93 | + .chip.imgchip .imgcost { |
| 94 | + opacity: .55; margin-left: 5px; padding: 1px 4px; border-radius: 3px; |
| 95 | + background: rgba(127,127,127,.16); font-size: 10px; |
| 96 | + } |
| 97 | + .chip.imgchip .imgthumb { cursor: zoom-in; } |
| 98 | + |
| 99 | + /* Click a thumbnail (in the tray or the transcript) to see it full size. A 28px thumb cannot tell |
| 100 | + you WHICH screenshot you attached, and that is the one thing you want to check before sending. */ |
| 101 | + #imgzoom { |
| 102 | + position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; |
| 103 | + background: rgba(0,0,0,.72); cursor: zoom-out; padding: 32px; |
| 104 | + } |
| 105 | + #imgzoom[hidden] { display: none; } |
| 106 | + #imgzoom img { max-width: 100%; max-height: 100%; border-radius: 6px; box-shadow: 0 8px 40px rgba(0,0,0,.5); } |
70 | 107 | #attachImg { display: inline-flex; align-items: center; justify-content: center; } |
71 | 108 |
|
72 | 109 | /* ---- attached images ---- */ |
|
91 | 128 | .chip.imgchip .imgx:hover { opacity: 1; background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.45)); } |
92 | 129 | .chip.imgchip .imgx:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; opacity: 1; } |
93 | 130 | .msgimg { |
94 | | - display: block; max-width: min(320px, 100%); max-height: 240px; width: auto; height: auto; |
| 131 | + display: block; max-width: min(320px, 100%); max-height: 240px; width: auto; height: auto; cursor: zoom-in; |
95 | 132 | border-radius: 6px; border: 1px solid var(--border); margin: 0 0 8px; |
96 | 133 | } |
97 | 134 | /* Whole-panel drop target: the transcript is a far bigger target than the composer, and someone |
|
1342 | 1379 | never a gap between actions where nothing shows. Its label tracks the current activity. --> |
1343 | 1380 | <div id="workbar" hidden role="status" aria-live="polite"><span class="wspin" aria-hidden="true"></span><span class="wtext shimmer">Working…</span></div> |
1344 | 1381 |
|
| 1382 | + <div id="imgzoom" hidden role="dialog" aria-modal="true" aria-label="Attached image"><img alt=""></div> |
| 1383 | + |
1345 | 1384 | <div id="composer"> |
1346 | 1385 | <div id="chips"></div> |
| 1386 | + <div id="imgnote" role="status" aria-live="polite" hidden></div> |
1347 | 1387 | <textarea id="input" rows="2" placeholder="Give the agent a goal…"></textarea> |
1348 | 1388 | <div id="toolbar"> |
1349 | 1389 | <div class="left"> |
|
1583 | 1623 | '<span class="name">' + esc(f.name) + '</span>' + |
1584 | 1624 | '<span class="x" data-id="' + esc(f.id) + '" title="Remove">✕</span></span>'; |
1585 | 1625 | } |
1586 | | - /** An attached image: its own thumbnail, its real size, and a way to take it back off. */ |
| 1626 | + /** What this image will actually cost the request, in visual tokens (28px patches, tier-capped). */ |
| 1627 | + function imgTokens(w, h){ |
| 1628 | + if (!(w > 0) || !(h > 0)) { return 0; } |
| 1629 | + const cap = 4784; // high-res tier ceiling; the server caps here whatever we send |
| 1630 | + let s2 = 1, t = Math.ceil(w / 28) * Math.ceil(h / 28); |
| 1631 | + while (t > cap && s2 > 0.05) { s2 *= 0.98; t = Math.ceil(w * s2 / 28) * Math.ceil(h * s2 / 28); } |
| 1632 | + return t; |
| 1633 | + } |
| 1634 | + |
| 1635 | + /** |
| 1636 | + * An attached image: its thumbnail, its size, what it will cost, and a way to take it back off. |
| 1637 | + * |
| 1638 | + * The cost is shown because this product meters credits per turn and an image is not a rounding |
| 1639 | + * error — a full-size screenshot is a few thousand input tokens. Someone deciding whether to |
| 1640 | + * attach three of them should be able to see that before they send, not after they are billed. |
| 1641 | + */ |
1587 | 1642 | function imgChip(im){ |
1588 | | - return '<span class="chip imgchip" title="' + escAttr(im.w + '×' + im.h + ' · ' + fmtKB(im.bytes)) + '">' |
1589 | | - + '<img class="imgthumb" src="' + escAttr(im.url) + '" alt="">' |
1590 | | - + '<span class="imgmeta">' + im.w + '×' + im.h + '</span>' |
| 1643 | + if (im.loading) { |
| 1644 | + return '<span class="chip imgchip loading" title="Preparing ' + escAttr(im.name || 'image') + '…">' |
| 1645 | + + '<span class="imgthumb imgskel" aria-hidden="true"></span>' |
| 1646 | + + '<span class="imgmeta">preparing…</span>' |
| 1647 | + + '</span>'; |
| 1648 | + } |
| 1649 | + const tok = imgTokens(im.w, im.h); |
| 1650 | + // Compact on the chip, exact in the tooltip. A tray of attachments is scanned, not read — and |
| 1651 | + // three chips at full width wrap onto a second row, which makes the composer jump as you paste. |
| 1652 | + const short = tok >= 1000 ? (tok / 1000).toFixed(1) + 'k' : String(tok); |
| 1653 | + const title = im.w + '×' + im.h + ' · ' + fmtKB(im.bytes) + ' · ~' |
| 1654 | + + tok.toLocaleString(CREDIT_LOCALE) + ' input tokens · click to view full size'; |
| 1655 | + return '<span class="chip imgchip" title="' + escAttr(title) + '">' |
| 1656 | + + '<img class="imgthumb" src="' + escAttr(im.url) + '" alt="" data-img="' + escAttr(im.id) + '">' |
| 1657 | + + '<span class="imgmeta">' + im.w + '×' + im.h + '<span class="imgcost">' + short + '</span></span>' |
1591 | 1658 | + '<span class="x imgx" data-img="' + escAttr(im.id) + '" role="button" tabindex="0" aria-label="Remove image">×</span>' |
1592 | 1659 | + '</span>'; |
1593 | 1660 | } |
|
3163 | 3230 | let pendingImages = []; // { id, url, w, h, media_type, base64, bytes } |
3164 | 3231 | let imgSeq = 0; |
3165 | 3232 |
|
3166 | | - /** A user-facing "that did not work" line. Routed to the host so it uses VS Code's own notice |
3167 | | - * surface rather than inventing a second one inside the transcript. */ |
3168 | | - function note(t){ vscode.postMessage({ type: 'notice', text: String(t || '') }); } |
| 3233 | + /** |
| 3234 | + * A user-facing "that did not work" line, shown WHERE IT HAPPENED — directly under the composer, |
| 3235 | + * beside the attachment that failed. |
| 3236 | + * |
| 3237 | + * This used to post to the host and surface as a VS Code notification in the far corner of the |
| 3238 | + * window. That is the wrong place for it: the notification appears seconds after the paste, a |
| 3239 | + * long way from the thing the person is looking at, and it outlives the moment it describes. |
| 3240 | + * Attachment problems are small, immediate and local, so the message should be too. |
| 3241 | + */ |
| 3242 | + let noteTimer = null; |
| 3243 | + function note(t){ |
| 3244 | + const text = String(t || ''); if (!text) { return; } |
| 3245 | + const el = document.getElementById('imgnote'); if (!el) { return; } |
| 3246 | + el.textContent = text; |
| 3247 | + el.hidden = false; |
| 3248 | + clearTimeout(noteTimer); |
| 3249 | + // Long enough to read a sentence; short enough that it does not linger over the next attempt. |
| 3250 | + noteTimer = setTimeout(function(){ el.hidden = true; }, 6000); |
| 3251 | + } |
3169 | 3252 |
|
3170 | 3253 | function fmtKB(n){ return n >= 1024 * 1024 ? (n / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB'; } |
3171 | 3254 |
|
|
3230 | 3313 | + (dropped > 0 ? dropped + ' were not attached — ' : '') + 'remove one to add another.'); |
3231 | 3314 | break; |
3232 | 3315 | } |
| 3316 | + // A placeholder goes in FIRST. Decoding and re-encoding a 4K screenshot takes long enough to |
| 3317 | + // read as "nothing happened", and the honest fix is to show the attachment immediately rather |
| 3318 | + // than to make the work faster. The chip is replaced in place when the bytes are ready. |
| 3319 | + const id = 'img' + (++imgSeq); |
| 3320 | + pendingImages.push({ id: id, loading: true, name: f.name || 'image' }); |
| 3321 | + renderChips(); |
3233 | 3322 | try { |
3234 | 3323 | const im = await normalizeImage(f); |
3235 | | - im.id = 'img' + (++imgSeq); |
| 3324 | + im.id = id; |
3236 | 3325 | im.url = 'data:' + im.media_type + ';base64,' + im.base64; |
3237 | | - pendingImages.push(im); |
| 3326 | + const at = pendingImages.findIndex(function(x){ return x.id === id; }); |
| 3327 | + if (at < 0) { continue; } // removed while it was decoding — respect that |
| 3328 | + pendingImages[at] = im; |
3238 | 3329 | renderChips(); |
3239 | | - } catch (e) { note(String((e && e.message) || e)); } |
| 3330 | + } catch (e) { |
| 3331 | + pendingImages = pendingImages.filter(function(x){ return x.id !== id; }); |
| 3332 | + renderChips(); |
| 3333 | + note(String((e && e.message) || e)); |
| 3334 | + } |
3240 | 3335 | } |
3241 | 3336 | return true; |
3242 | 3337 | } |
3243 | 3338 |
|
3244 | 3339 | function removeImage(id){ pendingImages = pendingImages.filter(function(i){ return i.id !== id; }); renderChips(); } |
3245 | 3340 |
|
| 3341 | + /** Full-size view of an attachment. Escape or a click anywhere closes it. */ |
| 3342 | + function zoomImage(src){ |
| 3343 | + const z = document.getElementById('imgzoom'); if (!z || !src) { return; } |
| 3344 | + z.querySelector('img').src = src; |
| 3345 | + z.hidden = false; |
| 3346 | + } |
| 3347 | + function closeZoom(){ const z = document.getElementById('imgzoom'); if (z) { z.hidden = true; z.querySelector('img').src = ''; } } |
| 3348 | + document.getElementById('imgzoom').addEventListener('click', closeZoom); |
| 3349 | + document.addEventListener('keydown', function(e){ |
| 3350 | + if (e.key === 'Escape' && !document.getElementById('imgzoom').hidden) { e.stopPropagation(); closeZoom(); } |
| 3351 | + }, true); |
| 3352 | + // Delegated so it covers both the composer tray and every image already in the transcript. |
| 3353 | + document.addEventListener('click', function(e){ |
| 3354 | + const t = e.target; |
| 3355 | + if (!t || !(t.classList && (t.classList.contains('imgthumb') || t.classList.contains('msgimg')))) { return; } |
| 3356 | + const im = pendingImages.find(function(x){ return x.id === t.getAttribute('data-img'); }); |
| 3357 | + zoomImage(im ? im.url : t.getAttribute('src')); |
| 3358 | + }); |
| 3359 | + |
3246 | 3360 | /** Reflect how full the image tray is on the attach button, so the cap is visible before it bites. */ |
3247 | 3361 | function syncAttachImgBtn(){ |
3248 | 3362 | const b = document.getElementById('attachImg'); if (!b) { return; } |
|
0 commit comments