diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php index 8a39801..d4a6bb4 100644 --- a/Plugin/unraid/include/ai_chat.php +++ b/Plugin/unraid/include/ai_chat.php @@ -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(); + ``; } + // 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 `
` - + `
` - + `${groupOpen ? '▾' : '▸'}` - + `${esc(name)}` - + `${mine.length}` - + `
` - + (groupOpen - ? `
` - + (mine.length ? mine.map(c => rowHtml(c, P)).join('') - : '
none yet
') - + `
` - : '') - + `
` + const open = !!openSet[k]; + html += `
` + + `
` + + `${open ? '▾' : '▸'}` + + `${esc(name)}` + + `${mine.length}` + + `
` + + (open + ? `
` + + (mine.length ? mine.map(c => rowHtml(c, P)).join('') + : '
none yet
') + + `
` + : '') + + `
`; + } + 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 `
` + + `
` + + `Last ${n} conversations` + + `
` + `
`; } function render() { if (!rows.length) { - box.innerHTML = groupHtml() + '
no saved conversations yet
'; + // 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 = '
no saved conversations yet
'; 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() - + '
' + rows.map(c => rowHtml(c, P)).join('') + '
'; + + (GROUPED ? recentBanner(recent.length) : '') + + '
' + recent.map(c => rowHtml(c, P)).join('') + '
'; } 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; } diff --git a/Plugin/unraid/pages/monitor.php b/Plugin/unraid/pages/monitor.php index 8364c15..d774a03 100644 --- a/Plugin/unraid/pages/monitor.php +++ b/Plugin/unraid/pages/monitor.php @@ -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, });