From 3566a20ebbded754070fce66af48690d1a904879 Mon Sep 17 00:00:00 2001
From: Adam Malczewski <github@tradam.dev>
Date: Sat, 27 Jun 2026 04:18:59 +0900
Subject: feat(vision): image paste + transcript image rendering + vision badge
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Vision & vision-handoff frontend (consumes the backend's additive
wire@0.12.0 / transport-contract@0.22.0 image types — no version bump).

Contracts mirrored:
- .dispatch/wire.reference.md: ImageChunk added to the Chunk union +
  ImageChunk/ImageInput interfaces.
- .dispatch/transport-contract.reference.md: ChatRequest.images,
  ModelMetadata.vision, + ImageChunk/ImageInput re-exports.

Core (core/chunks):
- conformance: assertChunkExhaustive handles the new 'image' variant
  (the guard caught it — its purpose).
- appendUserMessage(state, text, images?) echoes a [text, image, ...]
  user run; the user-message event dedup scans the trailing user run
  (not just the last chunk) so an image-bearing echo doesn't duplicate
  the text; applyHistory's during-gen dedup matches a multi-chunk echo
  by content equality (chunkContentEquals + trailingRun helpers).

UI:
- ChatView renders user 'image' chunks as lazy <img> bubbles; a
  non-vision model's persisted [image, analysis-text] both render.
  read_image tool renders generically (no special-casing).
- Composer: clipboard paste / file picker / drag-drop of images ->
  base64 data URLs, thumbnail previews with remove, forwarded on
  chat.send (omitted when none). Image-only sends allowed; steering
  (chat.queue) never forwards images.
- ModelSelector: vision badge (isVisionModel) marks vision-capable
  models; indicator shows native-vision vs vision-handoff hint.

Store wiring: ChatStore.send + AppStore.send + App.svelte handleSend
thread images through; chat.send still omits cwd (only images added).

Verification: svelte-check 0/0; vitest 901/901 (run twice, +34 new);
biome clean; vite build OK. See backend-handoff.md §2j.

Not merged or pushed.
---
 src/features/chat/ui/ModelSelector.svelte | 46 +++++++++++++++++++++++++++++--
 1 file changed, 44 insertions(+), 2 deletions(-)

(limited to 'src/features/chat/ui/ModelSelector.svelte')

diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte
index 03acb79..11b9feb 100644
--- a/src/features/chat/ui/ModelSelector.svelte
+++ b/src/features/chat/ui/ModelSelector.svelte
@@ -1,20 +1,32 @@
 <script lang="ts">
-  import { joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";
+  import type { ModelMetadata } from "@dispatch/transport-contract";
+  import { isVisionModel, joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";
 
   let {
     models,
     selected,
     onSelect,
+    modelInfo = {},
   }: {
     models: readonly string[];
     selected: string;
     onSelect: (model: string) => void;
+    /**
+     * Per-model metadata from `GET /models` (`{ [name]: ModelMetadata }`).
+     * Used to show a "vision" badge next to models with `vision: true` (they
+     * natively accept images; others rely on the server's vision handoff).
+     * Optional — absent metadata → no badge (treated as non-vision).
+     */
+    modelInfo?: Readonly<Record<string, ModelMetadata>>;
   } = $props();
 
   const keys = $derived(modelKeys(models));
   const current = $derived(splitModelName(selected));
   const keyModels = $derived(modelsForKey(models, current.key));
 
+  // Whether the currently-selected full model name is vision-capable.
+  const selectedVision = $derived(isVisionModel(modelInfo, selected));
+
   // Switching key jumps to the first model available under it.
   function selectKey(key: string): void {
     const first = modelsForKey(models, key)[0] ?? "";
@@ -24,6 +36,11 @@
   function selectModel(model: string): void {
     onSelect(joinModelName(current.key, model));
   }
+
+  // The full `<key>/<model>` name for a model suffix under the current key.
+  function fullNameFor(modelSuffix: string): string {
+    return joinModelName(current.key, modelSuffix);
+  }
 </script>
 
 <div class="flex flex-col gap-2">
@@ -44,7 +61,32 @@
     aria-label="Model selector"
   >
     {#each keyModels as model (model)}
-      <option value={model}>{model}</option>
+      <option value={model}>
+        {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if}
+      </option>
     {/each}
   </select>
+  {#if selectedVision}
+    <div class="flex items-center gap-1 text-xs text-base-content/60">
+      <svg
+        xmlns="http://www.w3.org/2000/svg"
+        viewBox="0 0 24 24"
+        fill="none"
+        stroke="currentColor"
+        stroke-width="2"
+        stroke-linecap="round"
+        stroke-linejoin="round"
+        class="h-3.5 w-3.5"
+        aria-hidden="true"
+      >
+        <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
+        <circle cx="12" cy="12" r="3"></circle>
+      </svg>
+      <span>Vision — this model sees images natively</span>
+    </div>
+  {:else}
+    <div class="text-xs text-base-content/40">
+      Pasted images are auto-described (vision handoff)
+    </div>
+  {/if}
 </div>
-- 
cgit v1.2.3

