@@ -35,6 +35,9 @@ interface Props {
3535 onPromoteToAttachment? : (note : VisibleNote ) => void | Promise <void >;
3636 /** Whether the active note can be promoted to a content attachment. */
3737 canPromoteToAttachment? : (note : VisibleNote ) => boolean ;
38+ /** Object URLs for image attachments, keyed by vault path. An image with a
39+ * URL renders as a thumbnail; anything else stays a chip. */
40+ attachmentPreviewUrls? : ReadonlyMap <string , string >;
3841}
3942
4043let {
4548 onRemoveAttachment,
4649 onPromoteToAttachment,
4750 canPromoteToAttachment,
51+ attachmentPreviewUrls = new Map (),
4852}: Props = $props ();
4953
5054const tracker = new VisibleNotesTracker ();
@@ -242,16 +246,18 @@ function clearAllGraphNotes(evt: MouseEvent): void {
242246
243247// --- Attachment handlers ---
244248function attachmentIcon(attachment : ChatAttachment ): string {
245- return attachment .mimeType .startsWith (" image/" ) ? " image" : " paperclip" ;
249+ if (attachment .mimeType .startsWith (" image/" )) return " image" ;
250+ if (attachment .mimeType === " application/pdf" ) return " file-text" ;
251+ return " file" ;
246252}
247253
248- function onAttachmentClick( evt : MouseEvent , attachment : ChatAttachment ): void {
249- if ( Keymap . isModEvent ( evt )) {
250- evt . preventDefault ();
251- evt . stopPropagation ();
252- openLink ( attachment . vaultPath );
253- return ;
254- }
254+ function attachmentExtension( attachment : ChatAttachment ): string {
255+ return / \. ( \w + ) $ / . exec ( attachment . name )?.[ 1 ]?. toUpperCase () ?? " " ;
256+ }
257+
258+ function removeAttachment( evt : MouseEvent , attachment : ChatAttachment ) : void {
259+ evt . preventDefault () ;
260+ evt . stopPropagation ();
255261 onRemoveAttachment ?.(attachment );
256262}
257263
@@ -275,6 +281,54 @@ onDestroy(() => {
275281
276282{#if hasAny }
277283 <div class =" context-tray flex flex-row flex-wrap items-start gap-1.5 min-w-0 max-w-full" >
284+ <!-- Attachments: tiles on their own row, so what's being sent is visible
285+ at a glance rather than hidden behind a filename. Images and PDFs show
286+ a thumbnail; anything without one shows a file card. -->
287+ {#if attachments .length > 0 }
288+ <div class =" attachment-tiles" >
289+ {#each attachments as attachment (attachment .vaultPath )}
290+ {@const url = attachmentPreviewUrls .get (attachment .vaultPath )}
291+ {@const ext = attachmentExtension (attachment )}
292+ <div
293+ class =" attachment-tile"
294+ class:has-preview ={url }
295+ class:is-document ={! attachment .mimeType .startsWith (" image/" )}
296+ >
297+ <button
298+ type =" button"
299+ class =" attachment-tile-body"
300+ title ={` ${attachment .vaultPath } (click to open) ` }
301+ onclick ={() => openLink (attachment .vaultPath )}
302+ onmouseover ={(evt ) => previewLink (evt , attachment .vaultPath )}
303+ onfocus ={(evt ) => previewLink (evt , attachment .vaultPath )}
304+ >
305+ {#if url }
306+ <img src ={url } alt ={attachment .name } draggable =" false" />
307+ {#if ! attachment .mimeType .startsWith (" image/" ) && ext }
308+ <span class ="attachment-tile-badge" >{ext }</span >
309+ {/if }
310+ {:else }
311+ <div class ="attachment-card-icon" use:icon ={attachmentIcon (attachment )} style =" --icon-size: 16px" ></div >
312+ <span class ="attachment-card-name" >{attachment .name }</span >
313+ {#if ext }
314+ <span class ="attachment-card-ext" >{ext }</span >
315+ {/if }
316+ {/if }
317+ </button >
318+ <button
319+ type =" button"
320+ class =" attachment-tile-remove"
321+ title =" Remove attachment"
322+ aria-label ={` Remove ${attachment .name } ` }
323+ onclick ={(evt ) => removeAttachment (evt , attachment )}
324+ >
325+ <div class ="chip-icon" use:icon ={" x" } style =" --icon-size: 10px" ></div >
326+ </button >
327+ </div >
328+ {/each }
329+ </div >
330+ {/if }
331+
278332 <!-- Visible notes (auto references) -->
279333 {#each visibleNotes as note (note .file .path )}
280334 {@const deactivated = deactivatedPaths .has (note .file .path )}
@@ -378,24 +432,147 @@ onDestroy(() => {
378432 </button >
379433 {/if }
380434
381- <!-- Content attachments -->
382- {#each attachments as attachment (attachment .vaultPath )}
383- <button
384- type =" button"
385- class =" s2b-chip s2b-pill s2b-pill--interactive attachment"
386- title ={` ${attachment .vaultPath } (click to remove attachment) ` }
387- onclick ={(evt ) => onAttachmentClick (evt , attachment )}
388- onmouseover ={(evt ) => previewLink (evt , attachment .vaultPath )}
389- onfocus ={(evt ) => previewLink (evt , attachment .vaultPath )}
390- >
391- <div class ="chip-icon" use:icon ={attachmentIcon (attachment )} style =" --icon-size: 12px" ></div >
392- <span class ="chip-label" >{attachment .name }</span >
393- </button >
394- {/each }
395435 </div >
396436{/if }
397437
398438<style >
439+ /* `flex-basis: 100%` puts the tiles on a row of their own above the chips;
440+ the tray itself is a wrapping row. Tiles are 120px on desktop, 56px on a
441+ phone where the composer is narrow. */
442+ .attachment-tiles {
443+ --s2b-tile-size : 120px ;
444+ display : flex ;
445+ flex-wrap : wrap ;
446+ gap : 8px ;
447+ flex-basis : 100% ;
448+ min-width : 0 ;
449+ /* Room for the remove buttons, which overhang the tiles' top edge. */
450+ padding-top : 4px ;
451+ }
452+
453+ :global(body .is-phone ) .attachment-tiles {
454+ --s2b-tile-size : 56px ;
455+ gap : 6px ;
456+ }
457+
458+ .attachment-tile {
459+ position : relative ;
460+ height : var (--s2b-tile-size );
461+ width : calc (var (--s2b-tile-size ) * 1.75 );
462+ /* A narrow sidebar composer can be slimmer than a file card. */
463+ max-width : 100% ;
464+ flex : none ;
465+ }
466+
467+ .attachment-tile.has-preview {
468+ width : var (--s2b-tile-size );
469+ }
470+
471+ button .attachment-tile-body {
472+ position : relative ;
473+ display : flex ;
474+ flex-direction : column ;
475+ align-items : flex-start ;
476+ justify-content : space-between ;
477+ gap : 2px ;
478+ width : 100% ;
479+ height : 100% ;
480+ padding : 8px ;
481+ border : 1px solid var (--background-modifier-border );
482+ border-radius : var (--radius-m );
483+ overflow : hidden ;
484+ background : var (--background-secondary );
485+ color : var (--text-normal );
486+ box-shadow : none ;
487+ text-align : left ;
488+ white-space : normal ;
489+ cursor : var (--cursor );
490+ }
491+
492+ .has-preview button .attachment-tile-body {
493+ padding : 0 ;
494+ background : var (--background-modifier-hover );
495+ }
496+
497+ button .attachment-tile-body img {
498+ display : block ;
499+ width : 100% ;
500+ height : 100% ;
501+ object-fit : cover ;
502+ }
503+
504+ /* PDFs render page 1 at the top of the tile rather than centred, so the
505+ title area is what shows. */
506+ .is-document button .attachment-tile-body img {
507+ object-position : top ;
508+ }
509+
510+ .attachment-tile-badge {
511+ position : absolute ;
512+ left : 4px ;
513+ bottom : 4px ;
514+ padding : 0 4px ;
515+ border-radius : var (--radius-s );
516+ background : var (--background-primary );
517+ color : var (--text-muted );
518+ font-size : var (--font-ui-smaller );
519+ font-weight : var (--font-semibold );
520+ line-height : 1.5 ;
521+ }
522+
523+ .attachment-card-icon {
524+ display : flex ;
525+ color : var (--text-muted );
526+ }
527+
528+ .attachment-card-name {
529+ display : -webkit-box ;
530+ -webkit-box-orient : vertical ;
531+ -webkit-line-clamp : 2 ;
532+ line-clamp : 2 ;
533+ overflow : hidden ;
534+ overflow-wrap : anywhere;
535+ font-size : var (--font-ui-smaller );
536+ line-height : 1.3 ;
537+ }
538+
539+ .attachment-card-ext {
540+ color : var (--text-faint );
541+ font-size : var (--font-ui-smaller );
542+ font-weight : var (--font-semibold );
543+ }
544+
545+ /* On a phone the card is too short for icon + two lines + extension. */
546+ :global(body .is-phone ) .attachment-card-icon ,
547+ :global(body .is-phone ) .attachment-card-ext {
548+ display : none ;
549+ }
550+
551+ /* Always visible, not revealed on hover: mobile has no hover, and an
552+ opacity-0 reveal there turns every remove into a double tap. */
553+ button .attachment-tile-remove {
554+ position : absolute ;
555+ top : -6px ;
556+ right : -6px ;
557+ display : flex ;
558+ align-items : center ;
559+ justify-content : center ;
560+ width : 20px ;
561+ height : 20px ;
562+ padding : 0 ;
563+ border-radius : 50% ;
564+ border : 1px solid var (--background-modifier-border );
565+ background : var (--background-primary );
566+ color : var (--text-muted );
567+ box-shadow : none ;
568+ cursor : var (--cursor );
569+ }
570+
571+ button .attachment-tile-remove :hover {
572+ color : var (--text-normal );
573+ background : var (--background-modifier-hover );
574+ }
575+
399576 .s2b-chip {
400577 display : inline-flex ;
401578 align-items : center ;
@@ -418,19 +595,11 @@ onDestroy(() => {
418595 and tool cards green is DIFF-ADD semantics, so a green chip implied a
419596 pending mutation rather than "this file rides along with the message".
420597
421- The two chip families are separated by WEIGHT within that one hue, not by
422- colour (see the `.attachment` override below). A hollow chip reads as a
423- pointer to something; a filled chip reads as containing something — which
424- is exactly the difference: a reference sends a path the model may choose
425- to read, an attachment inlines the file's bytes into the message. It also
426- tracks how each is created: references appear on their own as you move
427- around the vault (ambient, so quiet), attachments are a deliberate act
428- (louder). And promoting one to the other visibly fills the chip, so the
429- paperclip action shows its own result.
430-
431- This carries real weight: the reference/attachment distinction drives
432- token cost and what reaches an untrusted provider, and it was previously
433- encoded only in a 12px icon plus a hover tooltip that mobile never shows. */
598+ Attachments aren't chips at all: they render as tiles on their own row
599+ (thumbnail or file card), which separates "a path the model may choose to
600+ read" from "bytes inlined into the message" far more plainly than a fill
601+ weight could. That distinction drives token cost and what reaches an
602+ untrusted provider, so it has to be visible without a hover tooltip. */
434603 /* Compact variant of the shared pill: inside the composer card the default
435604 4px vertical padding reads oversized next to the single text line below.
436605 Scoped to the tray so search-modal / history pills keep their size. */
@@ -452,16 +621,6 @@ onDestroy(() => {
452621 --s2b-pill-border-hover : color-mix(in srgb , var (--interactive-accent ) 30% , var (--background-modifier-border ));
453622 }
454623
455- /* Filled: attachments carry content, so they carry pigment. 12% is enough
456- to read as solid next to a hollow chip at 11px without competing with the
457- accent border of the focused composer around it. */
458- .context-tray :global(.s2b-chip.attachment ) {
459- --s2b-pill-bg : color-mix(in srgb , var (--interactive-accent ) 12% , var (--background-primary ));
460- --s2b-pill-border : color-mix(in srgb , var (--interactive-accent ) 24% , var (--background-modifier-border ));
461- --s2b-pill-bg-hover : color-mix(in srgb , var (--interactive-accent ) 17% , var (--background-primary ));
462- --s2b-pill-border-hover : color-mix(in srgb , var (--interactive-accent ) 32% , var (--background-modifier-border ));
463- }
464-
465624 /* Now that an ACTIVE reference chip is also page-filled (ghost), fill no
466625 longer separates deactivated from active — so the remaining cues have to
467626 carry it: no accent in the border at all, muted label, reduced opacity,
0 commit comments