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",
| |