diff --git a/CHANGELOG.md b/CHANGELOG.md index 30de7c5..a98d04b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.9.4] + +- Browse table's Model column now shows each chat's original (first-seen) model from the snapshot data, falling back to the current/inferred model when no snapshot exists +- Bounced chats (original model differs from current) get a `→` marker with a tooltip showing "Originally X, now Y" +- Model column sorting follows the displayed (original) model + +## [1.9.3] + +- Snapshot each conversation's current model to `chrome.storage.local` whenever the conversation list is fetched (browse page load or popup "Export All") — preserves the model before a chat gets bounced to a new one on model retirement +- Records first-seen model, current model, and a change history per conversation; only the raw API model is stored, never an inferred guess + ## [1.9.2] - Added Vitest test harness for `utils.js` (52 tests covering export logic, model name parsing, artifact extraction) diff --git a/TODO.md b/TODO.md index ce9ee37..8c7d20b 100644 --- a/TODO.md +++ b/TODO.md @@ -152,6 +152,9 @@ - Future: consider sourcing from a JSON config file or remote endpoint instead of hardcoded array - **Track model changes per conversation** + - **Phase 1 capture SHIPPED (v1.9.3)** — `recordModelSnapshots()` in `content.js` writes `modelSnapshots` to `chrome.storage.local` every time the conversation list is fetched (browse page load or popup "Export All"), not just on export. Stores `{firstSeen, firstSeenAt, current, currentAt, history[]}` per conversation UUID; raw API model only, never an inferred guess. + - **Browse-table display SHIPPED (v1.9.4)** — Model column shows the original (first-seen) model via `getDisplayModel()`, with a `→` bounce marker + tooltip when the current model differs. Falls back to current/inferred when no snapshot exists. + - Still pending: surface the snapshot in JSON exports (sidecar or inline field); optionally a dedicated "current model" column or filter for bounced chats - `conversation.model` from the API is the *current* model only — when chats get bounced (deprecation, guardrails kicking to Sonnet 4, etc.) the original model is lost - Symptom: chats created before Sonnet 4.5 existed now show "Sonnet 4.5" because that's their current default - diff --git a/chrome/browse.html b/chrome/browse.html index b13a65c..3374893 100644 --- a/chrome/browse.html +++ b/chrome/browse.html @@ -544,7 +544,15 @@ .model-badge.opus { color: var(--badge-opus); } - + + .model-bounced { + margin-left: 4px; + font-size: 12px; + color: var(--badge-default); + opacity: 0.7; + cursor: help; + } + .actions { display: flex; gap: 8px; diff --git a/chrome/browse.js b/chrome/browse.js index 94545fa..8a6ea00 100644 --- a/chrome/browse.js +++ b/chrome/browse.js @@ -47,6 +47,7 @@ let sortStack = []; // Track multi-level sorting: [{field: 'name', direction: 'a let selectedConversations = new Set(); // Track selected conversation IDs let lastCheckedIndex = null; // Track last checked checkbox for shift+click range selection let exportTimestamps = {}; // Map conversation UUID to last export timestamp +let modelSnapshots = {}; // Map conversation UUID to { firstSeen, current, ... } captured by content.js let statusFilter = 'all'; // 'all', 'new', 'exported' let dateFormat = 'mdy'; // 'mdy' or 'dmy' let timeFormat = '12h'; // '12h' or '24h' @@ -61,6 +62,33 @@ async function loadExportTimestamps() { }); } +// Model snapshots are written by content.js whenever the conversation list is +// fetched (see recordModelSnapshots). They preserve the original model even +// after a chat is bounced to a newer one on model retirement. +async function loadModelSnapshots() { + return new Promise((resolve) => { + chrome.storage.local.get(['modelSnapshots'], (result) => { + modelSnapshots = result.modelSnapshots || {}; + resolve(); + }); + }); +} + +// Resolve which model to show for a conversation: the original (first-seen) +// snapshot if we have one, otherwise the current/inferred model. Also reports +// whether the chat has since been bounced to a different model. +function getDisplayModel(conv) { + const snap = modelSnapshots[conv.uuid]; + if (snap && snap.firstSeen) { + return { + model: snap.firstSeen, + current: snap.current || snap.firstSeen, + bounced: !!snap.current && snap.current !== snap.firstSeen + }; + } + return { model: conv.model, current: conv.model, bounced: false }; +} + async function saveExportTimestamp(conversationId) { exportTimestamps[conversationId] = new Date().toISOString(); return new Promise((resolve) => { @@ -114,6 +142,7 @@ document.addEventListener('DOMContentLoaded', async () => { const loadingStart = Date.now(); await loadOrgId(); await loadExportTimestamps(); + await loadModelSnapshots(); await loadDateTimePrefs(); const elapsed = Date.now() - loadingStart; if (elapsed < 1000) await new Promise(r => setTimeout(r, 1000 - elapsed)); @@ -307,8 +336,8 @@ function sortConversations() { bVal = new Date(b.updated_at); break; case 'model': - aVal = formatModelName(a.model || '').toLowerCase(); - bVal = formatModelName(b.model || '').toLowerCase(); + aVal = formatModelName(getDisplayModel(a).model || '').toLowerCase(); + bVal = formatModelName(getDisplayModel(b).model || '').toLowerCase(); break; default: continue; @@ -393,7 +422,8 @@ function displayConversations() { const updatedTime = formatTime(updatedDt); const createdDate = formatDate(createdDt); const createdTime = formatTime(createdDt); - const modelBadgeClass = getModelBadgeClass(conv.model); + const modelInfo = getDisplayModel(conv); + const modelBadgeClass = getModelBadgeClass(modelInfo.model); const projectName = getProjectName(conv); const newUpdated = isNewOrUpdated(conv); @@ -412,8 +442,8 @@ function displayConversations() { ${escapeHtml(createdDate)}
${escapeHtml(createdTime)} - ${escapeHtml(formatModelName(conv.model))} - + ${escapeHtml(formatModelName(modelInfo.model))} + ${modelInfo.bounced ? `` : ''}
diff --git a/chrome/content.js b/chrome/content.js index 245c327..547b9fe 100644 --- a/chrome/content.js +++ b/chrome/content.js @@ -28,6 +28,43 @@ function recordExportTimestamps(conversationIds) { }); } +// Snapshot each conversation's current model so it survives a model bounce +// (e.g. when a model retires and Claude silently moves old chats onto a new +// one). Only the raw API model is recorded — never an inferred guess. +function recordModelSnapshots(conversations) { + if (!Array.isArray(conversations)) return; + chrome.storage.local.get(['modelSnapshots'], (result) => { + const snapshots = result.modelSnapshots || {}; + const now = new Date().toISOString(); + let changed = false; + for (const conv of conversations) { + const model = conv && conv.model; + const id = conv && conv.uuid; + if (!model || !id) continue; // skip null-model chats — don't snapshot a guess + const existing = snapshots[id]; + if (!existing) { + snapshots[id] = { + firstSeen: model, + firstSeenAt: now, + current: model, + currentAt: now, + history: [{ model, at: now }] + }; + changed = true; + } else if (existing.current !== model) { + existing.current = model; + existing.currentAt = now; + existing.history = existing.history || []; + existing.history.push({ model, at: now }); + changed = true; + } + } + if (changed) { + chrome.storage.local.set({ modelSnapshots: snapshots }); + } + }); +} + // Helper function to format datetime in local time for filenames function getLocalDateTimeString() { const now = new Date(); @@ -72,8 +109,10 @@ function getLocalDateTimeString() { if (!response.ok) { throw new Error(`Failed to fetch conversations: ${response.status}`); } - - return await response.json(); + + const conversations = await response.json(); + recordModelSnapshots(conversations); // capture current models before any bounce + return conversations; } // Handle messages from popup chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { diff --git a/chrome/manifest.json b/chrome/manifest.json index 1ccbe5b..c50a8f8 100644 --- a/chrome/manifest.json +++ b/chrome/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Claude Exporter", - "version": "1.9.2", + "version": "1.9.4", "description": "Export conversations and artifacts from Claude.ai", "permissions": [ "activeTab", diff --git a/firefox/browse.html b/firefox/browse.html index 1f91690..8376105 100644 --- a/firefox/browse.html +++ b/firefox/browse.html @@ -544,7 +544,15 @@ .model-badge.opus { color: var(--badge-opus); } - + + .model-bounced { + margin-left: 4px; + font-size: 12px; + color: var(--badge-default); + opacity: 0.7; + cursor: help; + } + .actions { display: flex; gap: 8px; diff --git a/firefox/browse.js b/firefox/browse.js index 8407a1f..79d2f9e 100644 --- a/firefox/browse.js +++ b/firefox/browse.js @@ -47,6 +47,7 @@ let sortStack = []; // Track multi-level sorting: [{field: 'name', direction: 'a let selectedConversations = new Set(); // Track selected conversation IDs let lastCheckedIndex = null; // Track last checked checkbox for shift+click range selection let exportTimestamps = {}; // Map conversation UUID to last export timestamp +let modelSnapshots = {}; // Map conversation UUID to { firstSeen, current, ... } captured by content.js let statusFilter = 'all'; // 'all', 'new', 'exported' let dateFormat = 'mdy'; // 'mdy' or 'dmy' let timeFormat = '12h'; // '12h' or '24h' @@ -61,6 +62,33 @@ async function loadExportTimestamps() { }); } +// Model snapshots are written by content.js whenever the conversation list is +// fetched (see recordModelSnapshots). They preserve the original model even +// after a chat is bounced to a newer one on model retirement. +async function loadModelSnapshots() { + return new Promise((resolve) => { + chrome.storage.local.get(['modelSnapshots'], (result) => { + modelSnapshots = result.modelSnapshots || {}; + resolve(); + }); + }); +} + +// Resolve which model to show for a conversation: the original (first-seen) +// snapshot if we have one, otherwise the current/inferred model. Also reports +// whether the chat has since been bounced to a different model. +function getDisplayModel(conv) { + const snap = modelSnapshots[conv.uuid]; + if (snap && snap.firstSeen) { + return { + model: snap.firstSeen, + current: snap.current || snap.firstSeen, + bounced: !!snap.current && snap.current !== snap.firstSeen + }; + } + return { model: conv.model, current: conv.model, bounced: false }; +} + async function saveExportTimestamp(conversationId) { exportTimestamps[conversationId] = new Date().toISOString(); return new Promise((resolve) => { @@ -114,6 +142,7 @@ document.addEventListener('DOMContentLoaded', async () => { const loadingStart = Date.now(); await loadOrgId(); await loadExportTimestamps(); + await loadModelSnapshots(); await loadDateTimePrefs(); const elapsed = Date.now() - loadingStart; if (elapsed < 1000) await new Promise(r => setTimeout(r, 1000 - elapsed)); @@ -307,8 +336,8 @@ function sortConversations() { bVal = new Date(b.updated_at); break; case 'model': - aVal = formatModelName(a.model || '').toLowerCase(); - bVal = formatModelName(b.model || '').toLowerCase(); + aVal = formatModelName(getDisplayModel(a).model || '').toLowerCase(); + bVal = formatModelName(getDisplayModel(b).model || '').toLowerCase(); break; default: continue; @@ -393,7 +422,8 @@ function displayConversations() { const updatedTime = formatTime(updatedDt); const createdDate = formatDate(createdDt); const createdTime = formatTime(createdDt); - const modelBadgeClass = getModelBadgeClass(conv.model); + const modelInfo = getDisplayModel(conv); + const modelBadgeClass = getModelBadgeClass(modelInfo.model); const projectName = getProjectName(conv); const newUpdated = isNewOrUpdated(conv); @@ -412,8 +442,8 @@ function displayConversations() { ${escapeHtml(createdDate)}
${escapeHtml(createdTime)} - ${escapeHtml(formatModelName(conv.model))} - + ${escapeHtml(formatModelName(modelInfo.model))} + ${modelInfo.bounced ? `` : ''}
diff --git a/firefox/content.js b/firefox/content.js index 9baee46..d238fa6 100644 --- a/firefox/content.js +++ b/firefox/content.js @@ -35,6 +35,43 @@ function recordExportTimestamps(conversationIds) { }); } +// Snapshot each conversation's current model so it survives a model bounce +// (e.g. when a model retires and Claude silently moves old chats onto a new +// one). Only the raw API model is recorded — never an inferred guess. +function recordModelSnapshots(conversations) { + if (!Array.isArray(conversations)) return; + chrome.storage.local.get(['modelSnapshots'], (result) => { + const snapshots = result.modelSnapshots || {}; + const now = new Date().toISOString(); + let changed = false; + for (const conv of conversations) { + const model = conv && conv.model; + const id = conv && conv.uuid; + if (!model || !id) continue; // skip null-model chats — don't snapshot a guess + const existing = snapshots[id]; + if (!existing) { + snapshots[id] = { + firstSeen: model, + firstSeenAt: now, + current: model, + currentAt: now, + history: [{ model, at: now }] + }; + changed = true; + } else if (existing.current !== model) { + existing.current = model; + existing.currentAt = now; + existing.history = existing.history || []; + existing.history.push({ model, at: now }); + changed = true; + } + } + if (changed) { + chrome.storage.local.set({ modelSnapshots: snapshots }); + } + }); +} + // Helper function to format datetime in local time for filenames function getLocalDateTimeString() { const now = new Date(); @@ -79,8 +116,10 @@ function getLocalDateTimeString() { if (!response.ok) { throw new Error(`Failed to fetch conversations: ${response.status}`); } - - return await response.json(); + + const conversations = await response.json(); + recordModelSnapshots(conversations); // capture current models before any bounce + return conversations; } // Handle messages from popup diff --git a/firefox/manifest.json b/firefox/manifest.json index 4f5bcf5..4d1090b 100644 --- a/firefox/manifest.json +++ b/firefox/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Claude Exporter", - "version": "1.9.2", + "version": "1.9.4", "description": "Export conversations and artifacts from Claude.ai", "permissions": [ "activeTab",