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:
@@ -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
|
||||
// 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.
|
||||
const GROUP = o.groupProfile || '';
|
||||
const GROUP_MAX = o.groupMax || 10;
|
||||
const GROUPED = o.grouped !== false; // one collapsible section per profile
|
||||
const GROUP_MAX = o.groupMax || 10; // per profile, and for the recent list below
|
||||
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) {
|
||||
const prof = (c.profile && c.profile !== 'chat' && P[c.profile]) ? P[c.profile].short : '';
|
||||
@@ -2190,33 +2196,62 @@ vv_ai_profiles_script();
|
||||
+ `</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() {
|
||||
if (!GROUP) return '';
|
||||
if (!GROUPED) return '';
|
||||
const P = window.VvAiProfiles || {};
|
||||
const name = (P[GROUP] && P[GROUP].short) || (GROUP === 'chat' ? 'General Chat' : GROUP);
|
||||
const mine = rows.filter(c => (c.profile || 'chat') === GROUP)
|
||||
.sort((a, b) => (b.updated || b.ts || 0) - (a.updated || a.ts || 0))
|
||||
const keys = Object.keys(P);
|
||||
if (!keys.length) return '';
|
||||
|
||||
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);
|
||||
return `<div class="vv-ai-cgroup">`
|
||||
+ `<div class="vv-ai-chead" data-grp="1" style="display:flex;align-items:center;gap:6px;
|
||||
cursor:pointer;padding:3px 4px;user-select:none;">`
|
||||
+ `<span style="color:#666;font-size:10px;">${groupOpen ? '▾' : '▸'}</span>`
|
||||
+ `<span style="color:#888;font-size:10px;font-weight:600;">${esc(name)}</span>`
|
||||
+ `<span style="color:#333;font-size:9px;">${mine.length}</span>`
|
||||
+ `</div>`
|
||||
+ (groupOpen
|
||||
? `<div class="vv-ai-clist">`
|
||||
+ (mine.length ? mine.map(c => rowHtml(c, P)).join('')
|
||||
: '<div class="vv-ai-none">none yet</div>')
|
||||
+ `</div>`
|
||||
: '')
|
||||
+ `<div style="border-bottom:1px solid #1b1b1b;margin:5px 0;"></div>`
|
||||
const open = !!openSet[k];
|
||||
html += `<div class="vv-ai-cgroup">`
|
||||
+ `<div class="vv-ai-chead" data-grp="${esc(k)}" style="display:flex;align-items:center;
|
||||
gap:6px;cursor:pointer;padding:3px 4px;user-select:none;">`
|
||||
+ `<span style="color:#666;font-size:10px;width:8px;">${open ? '▾' : '▸'}</span>`
|
||||
+ `<span style="color:#888;font-size:10px;font-weight:600;">${esc(name)}</span>`
|
||||
+ `<span style="color:#333;font-size:9px;">${mine.length}</span>`
|
||||
+ `</div>`
|
||||
+ (open
|
||||
? `<div class="vv-ai-clist">`
|
||||
+ (mine.length ? mine.map(c => rowHtml(c, P)).join('')
|
||||
: '<div class="vv-ai-none">none yet</div>')
|
||||
+ `</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>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
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;
|
||||
}
|
||||
// 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
|
||||
// of the point.
|
||||
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()
|
||||
+ '<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() {
|
||||
@@ -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.
|
||||
const grp = e.target.closest('[data-grp]');
|
||||
if (grp) {
|
||||
groupOpen = !groupOpen;
|
||||
try { localStorage.setItem(OPEN_KEY, groupOpen ? '1' : '0'); } catch (_) {}
|
||||
const k = grp.dataset.grp;
|
||||
openSet[k] = !openSet[k];
|
||||
try { localStorage.setItem(OPEN_KEY, JSON.stringify(openSet)); } catch (_) {}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2870,8 +2870,7 @@ if (document.getElementById('vv-mon-ai-chat')) {
|
||||
vvMonChatList = VvAiChatList({
|
||||
into: 'vv-mon-ai-chats',
|
||||
chat: vvMonChat,
|
||||
// This card is pinned to General Chat, so its own threads get the top section rather than
|
||||
// competing for position with everything the tabs have been doing.
|
||||
// Pinned to General Chat, so that section is the one open on arrival; the rest collapse.
|
||||
groupProfile: 'chat',
|
||||
groupMax: 10,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user