Conversation list: a collapsible section per AI profile, then a Last N banner over the recents

Profile order comes from the registry so sections keep their places as threads move, and the recents are capped to the same N rather than repeating everything above them.
This commit is contained in:
Gmer4Lfe
2026-08-17 16:06:28 -04:00
parent 83f3b7e226
commit e024a29157
2 changed files with 69 additions and 28 deletions
+68 -26
View File
@@ -2171,11 +2171,17 @@ vv_ai_profiles_script();
// been doing — the profile with the most threads wins the top of the list regardless of which // been doing — the profile with the most threads wins the top of the list regardless of which
// card you are looking at. Collapsed by default because the card resumes the newest of these // card you are looking at. Collapsed by default because the card resumes the newest of these
// anyway; opening it is for reaching the other nine. // anyway; opening it is for reaching the other nine.
const GROUP = o.groupProfile || ''; const GROUPED = o.grouped !== false; // one collapsible section per profile
const GROUP_MAX = o.groupMax || 10; const GROUP_MAX = o.groupMax || 10; // per profile, and for the recent list below
const OPEN_KEY = 'vvAiListOpen:' + (o.into || ''); const OPEN_KEY = 'vvAiListOpen:' + (o.into || '');
let groupOpen = false;
try { groupOpen = localStorage.getItem(OPEN_KEY) === '1'; } catch (_) {} // Which sections are open, by profile key. Remembered as a set rather than one flag: the
// point of per-profile sections is that they are asked about independently.
let openSet = {};
try { openSet = JSON.parse(localStorage.getItem(OPEN_KEY) || '{}') || {}; } catch (_) { openSet = {}; }
// The card's own profile starts open — it is the one its operator is most likely to want,
// and on a card pinned to a profile an all-collapsed list says nothing on arrival.
if (o.groupProfile && !(o.groupProfile in openSet)) openSet[o.groupProfile] = true;
function rowHtml(c, P) { function rowHtml(c, P) {
const prof = (c.profile && c.profile !== 'chat' && P[c.profile]) ? P[c.profile].short : ''; const prof = (c.profile && c.profile !== 'chat' && P[c.profile]) ? P[c.profile].short : '';
@@ -2190,33 +2196,62 @@ vv_ai_profiles_script();
+ `</div>`; + `</div>`;
} }
// One collapsible section per profile, each holding that profile's most recent GROUP_MAX.
//
// Profile order comes from the registry, not from the data, so the sections stay in the same
// places as threads come and go — a list that reorders itself under the cursor is a list you
// have to re-read every time. Profiles with nothing in them are still shown, with a count of
// zero: their absence would otherwise read as a bug on the day you first look for one.
function groupHtml() { function groupHtml() {
if (!GROUP) return ''; if (!GROUPED) return '';
const P = window.VvAiProfiles || {}; const P = window.VvAiProfiles || {};
const name = (P[GROUP] && P[GROUP].short) || (GROUP === 'chat' ? 'General Chat' : GROUP); const keys = Object.keys(P);
const mine = rows.filter(c => (c.profile || 'chat') === GROUP) if (!keys.length) return '';
.sort((a, b) => (b.updated || b.ts || 0) - (a.updated || a.ts || 0))
const byProf = {};
for (const c of rows) (byProf[c.profile || 'chat'] = byProf[c.profile || 'chat'] || []).push(c);
let html = '';
for (const k of keys) {
const name = (P[k] && P[k].short) || k;
const mine = (byProf[k] || [])
.slice().sort((a, b) => (b.updated || b.ts || 0) - (a.updated || a.ts || 0))
.slice(0, GROUP_MAX); .slice(0, GROUP_MAX);
return `<div class="vv-ai-cgroup">` const open = !!openSet[k];
+ `<div class="vv-ai-chead" data-grp="1" style="display:flex;align-items:center;gap:6px; html += `<div class="vv-ai-cgroup">`
cursor:pointer;padding:3px 4px;user-select:none;">` + `<div class="vv-ai-chead" data-grp="${esc(k)}" style="display:flex;align-items:center;
+ `<span style="color:#666;font-size:10px;">${groupOpen ? '▾' : '▸'}</span>` gap:6px;cursor:pointer;padding:3px 4px;user-select:none;">`
+ `<span style="color:#888;font-size:10px;font-weight:600;">${esc(name)}</span>` + `<span style="color:#666;font-size:10px;width:8px;">${open ? '▾' : '▸'}</span>`
+ `<span style="color:#333;font-size:9px;">${mine.length}</span>` + `<span style="color:#888;font-size:10px;font-weight:600;">${esc(name)}</span>`
+ `</div>` + `<span style="color:#333;font-size:9px;">${mine.length}</span>`
+ (groupOpen + `</div>`
? `<div class="vv-ai-clist">` + (open
+ (mine.length ? mine.map(c => rowHtml(c, P)).join('') ? `<div class="vv-ai-clist">`
: '<div class="vv-ai-none">none yet</div>') + (mine.length ? mine.map(c => rowHtml(c, P)).join('')
+ `</div>` : '<div class="vv-ai-none">none yet</div>')
: '') + `</div>`
+ `<div style="border-bottom:1px solid #1b1b1b;margin:5px 0;"></div>` : '')
+ `</div>`;
}
return html;
}
// The banner is a heading, not decoration: without it the rows below read as a sixth profile
// section rather than as the cross-profile recents.
function recentBanner(n) {
return `<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">`
+ `<div style="flex:1;border-bottom:1px solid #1e1e1e;"></div>`
+ `<span style="color:#4a4a4a;font-size:9px;text-transform:uppercase;
letter-spacing:.06em;white-space:nowrap;">Last ${n} conversations</span>`
+ `<div style="flex:1;border-bottom:1px solid #1e1e1e;"></div>`
+ `</div>`; + `</div>`;
} }
function render() { function render() {
if (!rows.length) { if (!rows.length) {
box.innerHTML = groupHtml() + '<div class="vv-ai-none">no saved conversations yet</div>'; // No sections on a genuinely empty store — five headings all reading 0 is a shape that
// implies something is filtered out rather than that nothing has been said yet.
box.innerHTML = '<div class="vv-ai-none">no saved conversations yet</div>';
return; return;
} }
// The scope is shown, not just stored. A Scheduler thread is about one script or log, // The scope is shown, not just stored. A Scheduler thread is about one script or log,
@@ -2227,8 +2262,14 @@ vv_ai_profiles_script();
// makes the rows that are genuinely different harder to pick out, which is the opposite // makes the rows that are genuinely different harder to pick out, which is the opposite
// of the point. // of the point.
const P = window.VvAiProfiles || {}; const P = window.VvAiProfiles || {};
// Capped to the same GROUP_MAX. Uncapped, this repeated most of what the sections above
// already show and pushed them off the top of a card that is only 300px tall.
const recent = rows.slice()
.sort((a, b) => (b.updated || b.ts || 0) - (a.updated || a.ts || 0))
.slice(0, GROUP_MAX);
box.innerHTML = groupHtml() box.innerHTML = groupHtml()
+ '<div class="vv-ai-clist">' + rows.map(c => rowHtml(c, P)).join('') + '</div>'; + (GROUPED ? recentBanner(recent.length) : '')
+ '<div class="vv-ai-clist">' + recent.map(c => rowHtml(c, P)).join('') + '</div>';
} }
function load() { function load() {
@@ -2241,8 +2282,9 @@ vv_ai_profiles_script();
// Before the row handler: the header sits above the rows and must not be read as one. // Before the row handler: the header sits above the rows and must not be read as one.
const grp = e.target.closest('[data-grp]'); const grp = e.target.closest('[data-grp]');
if (grp) { if (grp) {
groupOpen = !groupOpen; const k = grp.dataset.grp;
try { localStorage.setItem(OPEN_KEY, groupOpen ? '1' : '0'); } catch (_) {} openSet[k] = !openSet[k];
try { localStorage.setItem(OPEN_KEY, JSON.stringify(openSet)); } catch (_) {}
render(); render();
return; return;
} }
+1 -2
View File
@@ -2870,8 +2870,7 @@ if (document.getElementById('vv-mon-ai-chat')) {
vvMonChatList = VvAiChatList({ vvMonChatList = VvAiChatList({
into: 'vv-mon-ai-chats', into: 'vv-mon-ai-chats',
chat: vvMonChat, chat: vvMonChat,
// This card is pinned to General Chat, so its own threads get the top section rather than // Pinned to General Chat, so that section is the one open on arrival; the rest collapse.
// competing for position with everything the tabs have been doing.
groupProfile: 'chat', groupProfile: 'chat',
groupMax: 10, groupMax: 10,
}); });