Skip to content

Commit 53ff0b5

Browse files
Leo310claude
andauthored
feat(chat): attachments as preview tiles in the composer (#526)
* feat(chat): show image attachments as thumbnails in the composer Image attachments in the context tray render as 56px thumbnails on their own row (click opens the image, the corner × removes it) instead of a filename chip. Non-image attachments, and images whose file isn't in the vault, keep the chip. Co-Authored-By: Claude <noreply@anthropic.com> * fix(chat): thumbnail from the composer's blob preview, not a vault lookup A pasted or dropped image is written through the adapter, so the vault may not have indexed it yet when the tray looked it up, leaving a chip that never upgraded. Input already keeps an object URL per image attachment; pass that down instead. Co-Authored-By: Claude <noreply@anthropic.com> * feat(chat): every attachment is a composer tile; PDF page-1 thumbnails Tiles are 80px on desktop (56px on a phone). PDFs render page 1 through Obsidian's pdfjs with a PDF badge; text files and anything without a preview show a file card (icon, name, extension). Attachment chips are gone, along with their filled-pill styling. Co-Authored-By: Claude <noreply@anthropic.com> * fix(chat): composer card no longer creeps back after shrinking; drags don't scroll the thread - The card's min-height floor was derived from the card's own height, so when the tray shrank (removing an attachment tile) the floor held it open and only fell ~7px per resize tick: the editor jumped up, then the card slid down. Sum the floor from the tray and action rows instead. - Chromium auto-scrolls a scroller whose edge a drag hovers near, and the thread's bottom edge sits right above the composer, so dragging a file in ran the thread to the bottom. Make it non-user-scrollable while a drag is in progress. - Review: cap the PDF thumbnail canvas at a square; don't store a preview URL once the composer is destroyed. Co-Authored-By: Claude <noreply@anthropic.com> * fix(chat): only drags over this chat view lock its thread scroll Co-Authored-By: Claude <noreply@anthropic.com> * feat(chat): 120px attachment tiles on desktop Co-Authored-By: Claude <noreply@anthropic.com> * fix(chat): file cards never overflow a narrow composer Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c6efe58 commit 53ff0b5

4 files changed

Lines changed: 373 additions & 74 deletions

File tree

‎src/components/chat/ContextTray.svelte‎

Lines changed: 204 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -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
4043
let {
@@ -45,6 +48,7 @@ let {
4548
onRemoveAttachment,
4649
onPromoteToAttachment,
4750
canPromoteToAttachment,
51+
attachmentPreviewUrls = new Map(),
4852
}: Props = $props();
4953
5054
const tracker = new VisibleNotesTracker();
@@ -242,16 +246,18 @@ function clearAllGraphNotes(evt: MouseEvent): void {
242246
243247
// --- Attachment handlers ---
244248
function 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

Comments
 (0)