diff --git a/web/src/components/ArtifactPanel.svelte b/web/src/components/ArtifactPanel.svelte
index cbdc134..3c5053d 100644
--- a/web/src/components/ArtifactPanel.svelte
+++ b/web/src/components/ArtifactPanel.svelte
@@ -9,6 +9,7 @@
import { X, Clock } from 'lucide-svelte';
import { untrack } from 'svelte';
import { API_BASE, apiGet, apiPost } from '../lib/api.js';
+ import { createResizablePanel } from '../lib/resizable-panel.svelte.js';
import ArtifactList from './ArtifactList.svelte';
import ArtifactDetailHeader from './ArtifactDetailHeader.svelte';
import ArtifactDetailBody from './ArtifactDetailBody.svelte';
@@ -19,10 +20,11 @@
// ── Resizable panel width (drag-to-resize on the left edge) ───────────────
//
- // Width is persisted per-user in localStorage. Constraints keep the panel
- // usable on small screens while avoiding the chat getting crushed. The
- // handle uses the Pointer Events API (covers mouse + touch + pen) and is
- // keyboard-accessible as an ARIA separator.
+ // Width is persisted per-user in localStorage. The pointer/keyboard/
+ // persistence logic is shared with ThreadPanel via `createResizablePanel`
+ // (lib/resizable-panel.svelte.js) so a resize fix lands once for both
+ // panels. The handle uses the Pointer Events API (mouse + touch + pen) and
+ // is keyboard-accessible as an ARIA separator.
/** Minimum drag-resize width in px. */
const MIN_PANEL_WIDTH = 320;
@@ -30,166 +32,14 @@
const MAX_PANEL_WIDTH = 900;
/** Default width when no persisted value exists. */
const DEFAULT_PANEL_WIDTH = 380;
- /** Reserved width for the main chat area — panel can't crowd past this. */
- const MIN_CHAT_RESERVE = 200;
- /** Keyboard nudge step when the handle has focus (ArrowLeft/Right). */
- const KEY_STEP = 16;
/** localStorage key for persistence. */
const STORAGE_KEY = 'claude-comms:artifact-panel-width';
- /**
- * Safe localStorage wrapper mirroring the one in mqtt-store.svelte.js.
- * Tolerates private browsing / quota errors by silently no-oping.
- */
- const safeStorage = {
- getItem(key) {
- try {
- return typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null;
- } catch {
- return null;
- }
- },
- setItem(key, value) {
- try {
- if (typeof localStorage !== 'undefined') localStorage.setItem(key, value);
- } catch {
- // localStorage unavailable -- silently ignore
- }
- },
- };
-
- /**
- * Clamp a requested width to the allowed range, with the upper bound
- * further constrained by the viewport so the panel never covers the
- * whole screen on a laptop.
- */
- function clampWidth(w) {
- const viewport = typeof window !== 'undefined' ? window.innerWidth : 1600;
- const upper = Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, viewport - MIN_CHAT_RESERVE));
- if (!Number.isFinite(w)) return DEFAULT_PANEL_WIDTH;
- return Math.max(MIN_PANEL_WIDTH, Math.min(upper, w));
- }
-
- /** Read the persisted width (if any), clamped to the current viewport. */
- function initialPanelWidth() {
- const raw = safeStorage.getItem(STORAGE_KEY);
- const parsed = raw != null ? Number.parseInt(raw, 10) : NaN;
- return clampWidth(Number.isFinite(parsed) ? parsed : DEFAULT_PANEL_WIDTH);
- }
-
- let panelWidth = $state(initialPanelWidth());
- let isResizing = $state(false);
-
- /** @type {HTMLDivElement | null} */
- let resizeHandleEl = $state(null);
- /**
- * Offset between the cursor's clientX and the panel's left edge at the
- * moment drag started. Without this, the first pointermove would snap the
- * left edge onto the cursor, causing a visible jump equal to the handle
- * width (or further if the user clicked near the edge of the handle).
- */
- let dragOffsetX = 0;
-
- /**
- * pointerdown on the handle: capture the pointer so move/up fire on the
- * handle even if the cursor leaves the element. Flip into resizing mode
- * to suppress transitions and apply the body-level cursor override.
- * Record the cursor-to-left-edge offset so subsequent pointermove events
- * can preserve the grip position under the cursor (no jump).
- */
- function handleResizePointerDown(e) {
- // Ignore non-primary buttons to avoid right-click dragging.
- if (e.button !== 0 && e.pointerType === 'mouse') return;
- if (!resizeHandleEl) return;
- e.preventDefault();
- const viewport = typeof window !== 'undefined' ? window.innerWidth : 1600;
- const panelLeftEdge = viewport - panelWidth;
- dragOffsetX = e.clientX - panelLeftEdge;
- isResizing = true;
- try {
- resizeHandleEl.setPointerCapture(e.pointerId);
- } catch {
- // setPointerCapture can throw on detached elements; non-fatal.
- }
- }
-
- /**
- * pointermove while dragging: maintain the original cursor-to-left-edge
- * offset captured on pointerdown so the handle tracks smoothly under the
- * cursor instead of snapping to it. Clamped to [MIN, MAX].
- */
- function handleResizePointerMove(e) {
- if (!isResizing) return;
- const viewport = typeof window !== 'undefined' ? window.innerWidth : 1600;
- const desiredLeftEdge = e.clientX - dragOffsetX;
- const next = clampWidth(viewport - desiredLeftEdge);
- panelWidth = next;
- }
-
- /**
- * pointerup / pointercancel: release capture, exit resizing mode, and
- * persist the final width.
- */
- function handleResizePointerUp(e) {
- if (!isResizing) return;
- isResizing = false;
- if (resizeHandleEl) {
- try {
- resizeHandleEl.releasePointerCapture(e.pointerId);
- } catch {
- // Non-fatal if already released.
- }
- }
- safeStorage.setItem(STORAGE_KEY, String(Math.round(panelWidth)));
- }
-
- /**
- * Keyboard nudges for the ARIA separator. ArrowLeft grows the panel
- * (increases width), ArrowRight shrinks it, matching the visual metaphor
- * of dragging the handle horizontally. Home/End jump to the extremes.
- * Each committed change is persisted.
- */
- function handleResizeKeydown(e) {
- let next = panelWidth;
- switch (e.key) {
- case 'ArrowLeft':
- next = clampWidth(panelWidth + KEY_STEP);
- break;
- case 'ArrowRight':
- next = clampWidth(panelWidth - KEY_STEP);
- break;
- case 'Home':
- next = clampWidth(MAX_PANEL_WIDTH);
- break;
- case 'End':
- next = clampWidth(MIN_PANEL_WIDTH);
- break;
- default:
- return;
- }
- e.preventDefault();
- if (next !== panelWidth) {
- panelWidth = next;
- safeStorage.setItem(STORAGE_KEY, String(Math.round(panelWidth)));
- }
- }
-
- /**
- * Re-clamp the stored width whenever the viewport shrinks enough that
- * our current value would cover the chat area. Also cancels any in-flight
- * drag on window resize so we don't end up with stale capture state.
- */
- $effect(() => {
- if (typeof window === 'undefined') return;
- const onResize = () => {
- const clamped = clampWidth(panelWidth);
- if (clamped !== panelWidth) {
- panelWidth = clamped;
- safeStorage.setItem(STORAGE_KEY, String(Math.round(panelWidth)));
- }
- };
- window.addEventListener('resize', onResize);
- return () => window.removeEventListener('resize', onResize);
+ const resizer = createResizablePanel({
+ minWidth: MIN_PANEL_WIDTH,
+ maxWidth: MAX_PANEL_WIDTH,
+ defaultWidth: DEFAULT_PANEL_WIDTH,
+ storageKey: STORAGE_KEY,
});
// Shared fetch state (list view)
@@ -296,6 +146,28 @@
}
}
+ /**
+ * Given the artifact's version list and the currently-selected ("to")
+ * version, return the greatest EXISTING version strictly less than it, or
+ * `null` when no older version exists (oldest selected, single version, or
+ * a malformed list). This is the valid "from" version for a diff and the
+ * clamp that prevents a v0 (or otherwise non-existent) diff request.
+ *
+ * @param {Array<{version:number}>} versions
+ * @param {number|null|undefined} selected
+ * @returns {number|null}
+ */
+ function nearestOlderVersion(versions, selected) {
+ if (selected == null || !Array.isArray(versions)) return null;
+ let best = null;
+ for (const entry of versions) {
+ const v = entry?.version;
+ if (typeof v !== 'number' || v >= selected) continue;
+ if (best === null || v > best) best = v;
+ }
+ return best;
+ }
+
async function fetchArtifactDetail(name, version) {
const channel = store.activeChannel;
if (!channel || !name) return;
@@ -314,14 +186,15 @@
if (!data.channel) data.channel = channel;
selectedArtifact = data;
selectedVersion = data.version;
- // Initialise the compare slot to v(N-1) when multiple versions exist;
- // otherwise leave null so the Diff toggle is disabled.
- const versions = data.versions ?? [];
- if (versions.length > 1 && data.version != null) {
- compareVersion = data.version - 1;
- } else {
- compareVersion = null;
- }
+ // Initialise the compare slot to the nearest EXISTING older version.
+ // Never blindly use `data.version - 1`: that requests a non-existent
+ // v0 when the selected version is the oldest (e.g. v1 → v0 → 404), and
+ // also breaks when version numbers aren't contiguous. We pick the
+ // greatest available version strictly less than the selected one; when
+ // none exists (oldest version selected, or only one version) we leave
+ // compareVersion null so the diff body short-circuits to empty instead
+ // of fetching a missing version.
+ compareVersion = nearestOlderVersion(data.versions ?? [], data.version);
} catch (e) {
detailError = e.message;
} finally {
@@ -366,6 +239,29 @@
showVersionDropdown = !showVersionDropdown;
}
+ /**
+ * Close the primary version dropdown on an outside click. The dropdown's
+ * open state (`showVersionDropdown`) is owned here at the panel level (the
+ * header is a stateless child for this control), so the outside-click
+ * listener belongs here too — matching the pattern other menus use
+ * (ChannelContextMenu's window mousedown guard). The primary trigger +
+ * listbox both live inside `[data-testid="primary-version-selector"]`, so a
+ * mousedown anywhere outside that subtree closes the dropdown.
+ *
+ * NOTE: the "Compare:" dropdown's open state lives inside
+ * ArtifactDetailHeader (`showCompareDropdown`, local $state there) and so
+ * cannot be closed from this file — closing it on outside-click needs a
+ * one-line addition in ArtifactDetailHeader.svelte (out of this change's
+ * file set; flagged as a follow-up).
+ */
+ function handleVersionDropdownOutsideClick(e) {
+ if (!showVersionDropdown) return;
+ const node = e.target;
+ const el = node instanceof Element ? node : (node?.parentElement ?? null);
+ if (el && el.closest('[data-testid="primary-version-selector"]')) return;
+ showVersionDropdown = false;
+ }
+
/**
* Handle the `[Content | Diff]` segmented toggle. Accepts 'content' or
* 'diff'; guards against meaningless diff requests (only one version).
@@ -373,7 +269,11 @@
function handleSetViewMode(mode) {
if (mode !== 'content' && mode !== 'diff') return;
const versions = selectedArtifact?.versions ?? [];
- if (mode === 'diff' && versions.length < 2) return;
+ // Diff is meaningful only when there are 2+ versions AND a valid older
+ // version exists to compare against (compareVersion non-null). The latter
+ // guards the oldest-version case so we never enter a diff that would
+ // request a non-existent version.
+ if (mode === 'diff' && (versions.length < 2 || compareVersion == null)) return;
viewMode = mode;
}
@@ -695,13 +595,15 @@
}
+