Merge branch 'testing'

# Conflicts:
#	chrome/manifest.json
#	firefox/manifest.json
This commit is contained in:
tux tucker 2026-05-14 16:55:32 -04:00
commit 93113209f2
10 changed files with 186 additions and 18 deletions

View file

@ -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)

View file

@ -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
-

View file

@ -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;

View file

@ -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() {
<td class="date">${escapeHtml(createdDate)}<br><span class="time">${escapeHtml(createdTime)}</span></td>
<td>
<span class="model-badge ${modelBadgeClass}">
${escapeHtml(formatModelName(conv.model))}
</span>
${escapeHtml(formatModelName(modelInfo.model))}
</span>${modelInfo.bounced ? `<span class="model-bounced" title="Originally ${escapeHtml(formatModelName(modelInfo.model))}, now ${escapeHtml(formatModelName(modelInfo.current))}"></span>` : ''}
</td>
<td>
<div class="actions">

View file

@ -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) => {

View file

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

View file

@ -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;

View file

@ -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() {
<td class="date">${escapeHtml(createdDate)}<br><span class="time">${escapeHtml(createdTime)}</span></td>
<td>
<span class="model-badge ${modelBadgeClass}">
${escapeHtml(formatModelName(conv.model))}
</span>
${escapeHtml(formatModelName(modelInfo.model))}
</span>${modelInfo.bounced ? `<span class="model-bounced" title="Originally ${escapeHtml(formatModelName(modelInfo.model))}, now ${escapeHtml(formatModelName(modelInfo.current))}"></span>` : ''}
</td>
<td>
<div class="actions">

View file

@ -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

View file

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