Skip to content

Commit 16ae48c

Browse files
committed
fix(images): the drop patch was inert, and the attachment UX now lives in the panel
THE CORE PATCH WAS NOT FIRING. An extension-created webview panel does not keep the viewType the extension registers: the API layer rewrites it (WebviewViewTypeTransformer('mainThreadWebview-') in mainThreadWebviewPanels.ts), so the editor input reads 'mainThreadWebview-levelcode.ai.chat'. My check compared against the bare id, was silently always false, and a plain drag still opened the file. That also explains the report that "drag and drop + shift works". Shift is a DIFFERENT path — isDragIntoEditorEvent makes onDragEnter return early, the overlay never appears, and handleDrop (where the patch lives) never runs. What worked was the webview's own uri-list fallback. I claimed the patch worked without ever seeing it fire. Now matches both forms. Re-appended to patches/levelcode-core.patch per NOTE 3 rather than regenerated wholesale, and `git apply --check --reverse` accepts it. THE UX PASS. Errors moved INTO the composer. They were VS Code notifications: appearing seconds later in the far corner, a long way from the paste, outliving the moment they described. Attachment problems are small, immediate and local, so the message is too — under the chips, aria-live, self-clearing after six seconds. A chip appears the INSTANT you paste. Decoding and re-encoding a 4K screenshot takes long enough to read as "nothing happened"; the honest fix is to show the attachment immediately, not to make the work faster. A shimmering placeholder is replaced in place when the bytes are ready — and if it was removed mid-decode it stays removed, rather than reappearing when its bytes arrive. The chip says what the image will COST — ~3.0k on the chip, the exact figure and size in the tooltip. This product meters credits per turn and an image is a few thousand input tokens; someone deciding whether to attach three should see that before sending, not after being billed. Compact because three full-width chips wrapped the tray onto a second row, which made the composer jump as you pasted. Click any thumbnail — tray or transcript — for a full-size view. A 28px thumb cannot tell you WHICH screenshot you attached, which is the one thing worth checking before sending. Escape closes it and drops the src, so the bytes do not stay live in the DOM. An existing guard caught a bare toLocaleString() in the new cost figure — the house rule is a fixed CREDIT_LOCALE so the editor and the dashboard never disagree about a number. Good catch by a test I did not write. Bypass-verified: errors thrown back to a toast; no placeholder; an image reappearing after being removed mid-decode; a failed image leaving its placeholder stuck; the cost shown full width; the zoom keeping bytes live on close; transcript images no longer opening. 21 tests in imageAttach, 37 suites green.
1 parent 68d487c commit 16ae48c

3 files changed

Lines changed: 288 additions & 11 deletions

File tree

extensions/levelcode-ai/media/chat.html

Lines changed: 125 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,43 @@
6767

6868
#attachImg .ci { width: 14px; height: 14px; display: block; }
6969
#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); }
70107
#attachImg { display: inline-flex; align-items: center; justify-content: center; }
71108

72109
/* ---- attached images ---- */
@@ -91,7 +128,7 @@
91128
.chip.imgchip .imgx:hover { opacity: 1; background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.45)); }
92129
.chip.imgchip .imgx:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; opacity: 1; }
93130
.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;
95132
border-radius: 6px; border: 1px solid var(--border); margin: 0 0 8px;
96133
}
97134
/* Whole-panel drop target: the transcript is a far bigger target than the composer, and someone
@@ -1342,8 +1379,11 @@
13421379
never a gap between actions where nothing shows. Its label tracks the current activity. -->
13431380
<div id="workbar" hidden role="status" aria-live="polite"><span class="wspin" aria-hidden="true"></span><span class="wtext shimmer">Working…</span></div>
13441381

1382+
<div id="imgzoom" hidden role="dialog" aria-modal="true" aria-label="Attached image"><img alt=""></div>
1383+
13451384
<div id="composer">
13461385
<div id="chips"></div>
1386+
<div id="imgnote" role="status" aria-live="polite" hidden></div>
13471387
<textarea id="input" rows="2" placeholder="Give the agent a goal…"></textarea>
13481388
<div id="toolbar">
13491389
<div class="left">
@@ -1583,11 +1623,38 @@
15831623
'<span class="name">' + esc(f.name) + '</span>' +
15841624
'<span class="x" data-id="' + esc(f.id) + '" title="Remove">✕</span></span>';
15851625
}
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+
*/
15871642
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>'
15911658
+ '<span class="x imgx" data-img="' + escAttr(im.id) + '" role="button" tabindex="0" aria-label="Remove image">×</span>'
15921659
+ '</span>';
15931660
}
@@ -3163,9 +3230,25 @@
31633230
let pendingImages = []; // { id, url, w, h, media_type, base64, bytes }
31643231
let imgSeq = 0;
31653232

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+
}
31693252

31703253
function fmtKB(n){ return n >= 1024 * 1024 ? (n / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB'; }
31713254

@@ -3230,19 +3313,50 @@
32303313
+ (dropped > 0 ? dropped + ' were not attached — ' : '') + 'remove one to add another.');
32313314
break;
32323315
}
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();
32333322
try {
32343323
const im = await normalizeImage(f);
3235-
im.id = 'img' + (++imgSeq);
3324+
im.id = id;
32363325
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;
32383329
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+
}
32403335
}
32413336
return true;
32423337
}
32433338

32443339
function removeImage(id){ pendingImages = pendingImages.filter(function(i){ return i.id !== id; }); renderChips(); }
32453340

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+
32463360
/** Reflect how full the image tray is on the attach button, so the cap is visible before it bites. */
32473361
function syncAttachImgBtn(){
32483362
const b = document.getElementById('attachImg'); if (!b) { return; }

extensions/levelcode-ai/test/imageAttach.test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,4 +258,51 @@ test('CAP: one setting, honoured on every route in, and visible before it bites'
258258
'the cap should be visible on the button before it refuses anything');
259259
});
260260

261+
test('UX: an attachment problem is reported where it happened, not in a corner toast', () => {
262+
// This used to post to the host and surface as a VS Code notification in the far corner of the
263+
// window — seconds later, a long way from the paste, and outliving the moment it described.
264+
const body = fnBody(html, 'note');
265+
assert.match(body, /getElementById\('imgnote'\)/, 'the notice must render in the composer');
266+
assert.ok(!/postMessage/.test(body), 'it must no longer be thrown to a global notification');
267+
assert.match(body, /setTimeout/, 'and it must clear itself rather than linger over the next try');
268+
assert.match(html, /id="imgnote"/, 'the notice element is missing from the composer');
269+
assert.match(html, /aria-live="polite"/, 'a screen reader must hear it too');
270+
});
271+
272+
test('UX: a chip appears the instant you paste, before the decode finishes', () => {
273+
// Decoding and re-encoding a 4K screenshot takes long enough to read as "nothing happened".
274+
const body = fnBody(html, 'attachImageFiles');
275+
assert.match(body, /loading: true/, 'no placeholder chip while the image is being prepared');
276+
assert.ok(body.indexOf('loading: true') < body.indexOf('await normalizeImage'),
277+
'the placeholder must go in BEFORE the work, or it is not feedback');
278+
assert.match(body, /at < 0.*continue|if \(at < 0\)/s,
279+
'an image removed mid-decode must stay removed, not reappear when its bytes arrive');
280+
assert.match(body, /pendingImages\.filter/, 'a failed image must not leave its placeholder behind');
281+
assert.match(fnBody(html, 'imgChip'), /im\.loading/, 'the chip must render the loading state');
282+
});
283+
284+
test('UX: the chip says what the image will cost, compactly', () => {
285+
// This product meters credits per turn and an image is not a rounding error. Someone deciding
286+
// whether to attach three should see that before they send, not after they are billed.
287+
const chip = fnBody(html, 'imgChip');
288+
assert.match(chip, /imgTokens\(im\.w, im\.h\)/, 'the chip must compute a real token cost');
289+
assert.match(chip, /toFixed\(1\) \+ 'k'/, 'compact on the chip — full width wraps the tray');
290+
assert.match(chip, /input tokens/, 'the exact figure belongs in the tooltip');
291+
292+
const t = fnBody(html, 'imgTokens');
293+
assert.match(t, /Math\.ceil\(w \/ 28\) \* Math\.ceil\(h \/ 28\)/, 'must be the real 28px patch formula');
294+
assert.match(t, /4784/, 'and clamped to the tier ceiling the server enforces anyway');
295+
});
296+
297+
test('UX: a thumbnail can be opened full size, and closed again', () => {
298+
// A 28px thumb cannot tell you WHICH screenshot you attached — the one thing worth checking
299+
// before sending.
300+
assert.match(html, /id="imgzoom"/, 'no full-size view');
301+
assert.match(html, /aria-modal="true"/, 'the overlay should announce itself as a dialog');
302+
const z = fnBody(html, 'closeZoom');
303+
assert.match(z, /src = ''/, 'closing must drop the src, or the bytes stay live in the DOM');
304+
assert.match(html, /e\.key === 'Escape'/, 'Escape must close it');
305+
assert.match(html, /classList\.contains\('msgimg'\)/, 'transcript images must open too, not just the tray');
306+
});
307+
261308
console.log('\nimageAttach: ' + n + ' tests passed.');

0 commit comments

Comments
 (0)