fix: model icons use inline opacity, DDG search upgrade, session save/load/sidebar persistence, header z-index
Some checks are pending
CI / build-check-test (push) Waiting to run

- ModelSelector: inline style opacity:0.3 instead of Tailwind class (fixes icons always lit)
- web-search: scrape lite.duckduckgo.com HTML with API fallback (fixes empty results)
- main.ts: auto-save session on agent_end event
- main.ts: header z-index:50 + overflow:visible (fixes View dropdown clipping)
- main.ts: refreshSidebar uses getAllMetadata() from IndexedDB
- main.ts: saveSession uses correct SessionsStore.save(data, metadata) API
This commit is contained in:
JAE 2026-03-27 19:11:28 +00:00
parent aca28ad0ec
commit db79dec9e1
3 changed files with 118 additions and 44 deletions

View file

@ -290,6 +290,7 @@ chatPanel.setAgent(agent, {
case "agent_end": case "agent_end":
if (typing) typing.hide(); if (typing) typing.hide();
saveSession();
break; break;
case "turn_start": case "turn_start":
@ -379,9 +380,31 @@ async function saveSession() {
if (!agent) return; if (!agent) return;
const msgs = agent.getMessages(); const msgs = agent.getMessages();
const title = msgs.find((m: AgentMessage) => m.role === "user")?.content?.toString().slice(0, 50) || "New chat"; const title = msgs.find((m: AgentMessage) => m.role === "user")?.content?.toString().slice(0, 50) || "New chat";
const state = agent.getState(); const agentState = agent.getState();
const id = state.sessionId || crypto.randomUUID(); const id = agentState.sessionId || crypto.randomUUID();
await sessionsStore.save(id, { state, title }); const now = new Date().toISOString();
try {
const sessionData = {
id,
title,
model: agentState.model,
thinkingLevel: agentState.thinkingLevel,
messages: agentState.messages || [],
createdAt: now,
lastModified: now,
};
const sessionMeta = {
id,
title,
createdAt: now,
lastModified: now,
messageCount: msgs.length,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
thinkingLevel: agentState.thinkingLevel || "off",
preview: msgs[msgs.length - 1]?.content?.toString().slice(0, 100) || "",
};
await sessionsStore.save(sessionData as any, sessionMeta as any);
} catch (_e) { /* store may not be ready */ }
const sidebar = document.querySelector("jae-session-sidebar") as JaeSessionSidebar; const sidebar = document.querySelector("jae-session-sidebar") as JaeSessionSidebar;
if (sidebar) { if (sidebar) {
sidebar.addSession({ id, title, date: new Date().toLocaleDateString(), pinned: false }); sidebar.addSession({ id, title, date: new Date().toLocaleDateString(), pinned: false });
@ -391,7 +414,19 @@ async function saveSession() {
async function refreshSidebar() { async function refreshSidebar() {
const sidebar = document.querySelector("jae-session-sidebar") as JaeSessionSidebar; const sidebar = document.querySelector("jae-session-sidebar") as JaeSessionSidebar;
if (sidebar) sidebar.refresh(); if (!sidebar) return;
try {
const allMeta = await sessionsStore.getAllMetadata();
for (const meta of allMeta) {
sidebar.addSession({
id: meta.id,
title: meta.title || "New chat",
date: meta.lastModified ? new Date(meta.lastModified).toLocaleDateString() : new Date().toLocaleDateString(),
pinned: false,
});
}
} catch (_e) { /* fallback if store not ready */ }
sidebar.refresh();
} }
function newSession() { function newSession() {
@ -586,7 +621,7 @@ function renderApp() {
html` html`
<div class="flex flex-col h-screen bg-background text-foreground dark"> <div class="flex flex-col h-screen bg-background text-foreground dark">
<!-- HEADER --> <!-- HEADER -->
<div class="flex items-center gap-2 px-3 shrink-0 border-b border-border/40 jae-glass" style="height:48px"> <div class="flex items-center gap-2 px-3 shrink-0 border-b border-border/40 jae-glass" style="height:48px;position:relative;z-index:50;overflow:visible">
<img src="/mascot/jae-default.png" class="w-7 h-7 rounded-full" alt="JAE" style="transition: transform 0.3s" @mouseenter=${(e: Event) => { (e.currentTarget as HTMLElement).style.transform = "scale(1.15) rotate(5deg)"; }} @mouseleave=${(e: Event) => { (e.currentTarget as HTMLElement).style.transform = ""; }} /> <img src="/mascot/jae-default.png" class="w-7 h-7 rounded-full" alt="JAE" style="transition: transform 0.3s" @mouseenter=${(e: Event) => { (e.currentTarget as HTMLElement).style.transform = "scale(1.15) rotate(5deg)"; }} @mouseleave=${(e: Event) => { (e.currentTarget as HTMLElement).style.transform = ""; }} />
<span class="text-sm font-bold text-foreground tracking-tight">JAE</span> <span class="text-sm font-bold text-foreground tracking-tight">JAE</span>
<jae-mood-indicator class="ml-1"></jae-mood-indicator> <jae-mood-indicator class="ml-1"></jae-mood-indicator>

View file

@ -456,10 +456,10 @@ export class ModelSelector extends DialogBase {
</div> </div>
<div class="flex items-center justify-between text-xs text-muted-foreground"> <div class="flex items-center justify-between text-xs text-muted-foreground">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="${model.reasoning ? "" : "opacity-30"}" title="Reasoning">${icon(Brain, "sm")}</span> <span style="${model.reasoning ? '' : 'opacity:0.3'}" title="Reasoning">${icon(Brain, "sm")}</span>
<span class="${model.input.includes("image") ? "" : "opacity-30"}" title="Vision">${icon(ImageIcon, "sm")}</span> <span style="${model.input.includes("image") ? '' : 'opacity:0.3'}" title="Vision">${icon(ImageIcon, "sm")}</span>
<span class="${(model as any).tags?.includes("tools") ? "" : "opacity-30"}" title="Tool Use">${icon(Wrench, "sm")}</span> <span style="${(model as any).tags?.includes("tools") ? '' : 'opacity:0.3'}" title="Tool Use">${icon(Wrench, "sm")}</span>
<span class="${(model as any).tags?.includes("code") || (model as any).tags?.includes("optimized-for-code") ? "" : "opacity-30"}" title="Code">${icon(Code, "sm")}</span> <span style="${(model as any).tags?.includes("code") || (model as any).tags?.includes("optimized-for-code") ? '' : 'opacity:0.3'}" title="Code">${icon(Code, "sm")}</span>
<span>${this.formatTokens(model.contextWindow)}K/${this.formatTokens(model.maxTokens)}K</span> <span>${this.formatTokens(model.contextWindow)}K/${this.formatTokens(model.maxTokens)}K</span>
</div> </div>
<span>${formatModelCost(model.cost)}</span> <span>${formatModelCost(model.cost)}</span>

View file

@ -30,31 +30,70 @@ interface WebSearchParams {
async function fetchDuckDuckGo(query: string, limit: number): Promise<WebSearchResult[]> { async function fetchDuckDuckGo(query: string, limit: number): Promise<WebSearchResult[]> {
const encoded = encodeURIComponent(query); const encoded = encodeURIComponent(query);
const res = await fetch(
`https://api.duckduckgo.com/?q=${encoded}&format=json&no_redirect=1&no_html=1&skip_disambig=1`,
);
if (!res.ok) throw new Error(`Search returned ${res.status}`);
const data = (await res.json()) as any;
const results: WebSearchResult[] = []; const results: WebSearchResult[] = [];
if (data.AbstractText && data.AbstractURL) {
results.push({ title: data.Heading || query, url: data.AbstractURL, snippet: data.AbstractText }); // Method 1: DuckDuckGo HTML lite (most reliable)
} try {
for (const topic of data.RelatedTopics || []) { const res = await fetch(`https://lite.duckduckgo.com/lite/?q=${encoded}`, {
if (results.length >= limit) break; headers: {
if (topic.FirstURL && topic.Text) { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
results.push({ title: topic.Text.split(" - ")[0], url: topic.FirstURL, snippet: topic.Text }); "Accept": "text/html",
} else if (topic.Topics) { },
for (const sub of topic.Topics) { });
if (res.ok) {
const text = await res.text();
// Parse HTML lite results - links are in <a> with class="result-link"
const linkRegex = /<a[^>]+rel="nofollow"[^>]+href="([^"]+)"[^>]*>([^<]+)<\/a>/gi;
const snippetRegex = /<td[^>]*class="result-snippet"[^>]*>([^<]*(?:<[^>]*>[^<]*)*)<\/td>/gi;
const links: {url: string, title: string}[] = [];
const snippets: string[] = [];
let m;
while ((m = linkRegex.exec(text)) !== null) {
const url = m[1];
const title = m[2].trim();
if (url.startsWith("http") && !url.includes("duckduckgo.com")) {
links.push({ url, title });
}
}
while ((m = snippetRegex.exec(text)) !== null) {
snippets.push(m[1].replace(/<[^>]+>/g, "").trim());
}
for (let i = 0; i < Math.min(links.length, limit); i++) {
results.push({
title: links[i].title,
url: links[i].url,
snippet: snippets[i] || "",
});
}
if (results.length > 0) return results;
}
} catch (_e) { /* fall through to method 2 */ }
// Method 2: DuckDuckGo Instant Answer API (fallback)
try {
const res = await fetch(
`https://api.duckduckgo.com/?q=${encoded}&format=json&no_redirect=1&no_html=1&skip_disambig=1`,
);
if (res.ok) {
const data = (await res.json()) as any;
if (data.AbstractText && data.AbstractURL) {
results.push({ title: data.Heading || query, url: data.AbstractURL, snippet: data.AbstractText });
}
for (const topic of data.RelatedTopics || []) {
if (results.length >= limit) break; if (results.length >= limit) break;
if (sub.FirstURL && sub.Text) if (topic.FirstURL && topic.Text) {
results.push({ title: sub.Text.split(" - ")[0], url: sub.FirstURL, snippet: sub.Text }); results.push({ title: topic.Text.split(" - ")[0], url: topic.FirstURL, snippet: topic.Text });
} else if (topic.Topics) {
for (const sub of topic.Topics) {
if (results.length >= limit) break;
if (sub.FirstURL && sub.Text)
results.push({ title: sub.Text.split(" - ")[0], url: sub.FirstURL, snippet: sub.Text });
}
}
} }
} }
} } catch (_e) { /* no-op */ }
for (const r of data.Results || []) {
if (results.length >= limit) break;
if (r.FirstURL && r.Text) results.push({ title: r.Title || r.Text, url: r.FirstURL, snippet: r.Text });
}
return results.slice(0, limit); return results.slice(0, limit);
} }
@ -89,20 +128,20 @@ class WebSearchRenderer implements ToolRenderer<WebSearchParams, WebSearchDetail
const details = result.details; const details = result.details;
return { return {
content: html` content: html`
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
${renderHeader(state, Globe, `Web Search: ${details.query}`)} ${renderHeader(state, Globe, `Web Search: ${details.query}`)}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
${details.results.map( ${details.results.map(
(r) => html` (r) => html`
<div class="flex flex-col gap-0.5 p-2 rounded border border-border bg-background"> <div class="flex flex-col gap-0.5 p-2 rounded border border-border bg-background">
<a href=${r.url} target="_blank" rel="noopener" class="text-sm font-medium text-primary hover:underline">${r.title}</a> <a href=${r.url} target="_blank" rel="noopener" class="text-sm font-medium text-primary hover:underline">${r.title}</a>
<span class="text-xs text-muted-foreground truncate">${r.url}</span> <span class="text-xs text-muted-foreground truncate">${r.url}</span>
<span class="text-xs text-foreground mt-1">${r.snippet}</span> <span class="text-xs text-foreground mt-1">${r.snippet}</span>
</div> </div>
`, `,
)} )}
</div> </div>
</div>`, </div>`,
isCustom: false, isCustom: false,
}; };
} }