diff --git a/src/client/app.css b/src/client/app.css index 938dd07..6dff325 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -2424,14 +2424,14 @@ body:has(.connstat[data-state="offline"]) .banner { } .projgrid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 14px; } .projcard { display: flex; flex-direction: column; - min-height: 150px; - padding: 18px 20px; + min-height: 112px; + padding: 14px 16px; border: 1px solid var(--rule); border-radius: var(--radius-lg); background: var(--bg-raise); diff --git a/src/client/app.js b/src/client/app.js index eea3f29..e5291a4 100644 --- a/src/client/app.js +++ b/src/client/app.js @@ -1400,6 +1400,11 @@ import { mountSidebar } from "./sidebar.js"; connTimer = null; } convId = id; + // Clear the previous conversation's thread synchronously, before the async + // history load. Otherwise, when the chat shell is re-revealed after a detour + // through a satellite view, it briefly shows the old conversation (a full + // round-trip for a new chat) until loadHistoryThenStream's fetch returns. + clearThread(); // Reset the context-window gauge for the conversation we're switching to; // replay of an existing chat's last turn repopulates it, a new chat leaves // it hidden (no usage yet) instead of showing the previous chat's number. @@ -1734,7 +1739,14 @@ import { mountSidebar } from "./sidebar.js"; // Conversations page; this page just tells it how to open/create a chat. var conversations = []; var dialogs = mountDialogs(); + // The section the router is currently showing ("chat" | "conversations" | + // "projects" | "settings"). The sidebar's "New chat" highlight is a chat-view + // concern, so it only lights up while we're actually on chat. + var currentSection = "chat"; var sidebar = mountSidebar({ + onChat: function () { + return currentSection === "chat"; + }, // Route through the router so these work from any view: if a satellite is // mounted, it's torn down and the chat shell comes back; on chat, it's a // normal soft-nav. `router` is assigned below (before any user click fires). @@ -1963,8 +1975,12 @@ import { mountSidebar } from "./sidebar.js"; // page's HTML; soft-nav has to do it live). "New chat" and the active recent are // handled separately by sidebar.render, so chat clears both top rows. function setActiveNav(section) { + currentSection = section; document.getElementById("chatsBtn").classList.toggle("active", section === "conversations"); document.getElementById("projectsBtn").classList.toggle("active", section === "projects"); + // Leaving chat clears the "New chat" highlight now (views like Projects don't + // re-render the sidebar); on chat, enterChat's sidebar.render sets it. + if (section !== "chat") document.getElementById("new").classList.remove("active"); } // Shared context handed to mounted views: the live sidebar, the dialog helpers, diff --git a/src/client/index.html b/src/client/index.html index 02ad2ce..e73c14e 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -37,7 +37,7 @@
diff --git a/src/client/sidebar.js b/src/client/sidebar.js index a6a05c4..967e0d9 100644 --- a/src/client/sidebar.js +++ b/src/client/sidebar.js @@ -111,8 +111,12 @@ export function mountSidebar(config) { // Highlight "New chat" while the open conversation is brand new — it has an // id but isn't in the saved list yet (nothing persisted). Not on the // Conversations page, which has its own active nav row. + // "New chat" lights up only on the chat view, and only for a brand-new chat + // that has an id but isn't saved yet. onChat() lets the shell say whether the + // chat view is even showing (a satellite view must not keep this lit). + var onChat = config.onChat ? config.onChat() : config.active !== "conversations"; var isNewChat = - config.active !== "conversations" && + onChat && !!active && !conversations.some(function (c) { return c.id === active; diff --git a/src/client/views/project.js b/src/client/views/project.js index 0c8b619..2ab31c9 100644 --- a/src/client/views/project.js +++ b/src/client/views/project.js @@ -82,6 +82,22 @@ export function mount(root, params, ctx) { var projectId = params.id; var project = null; var pinProjects = null; // cached list from /api/lard/projects + var abort = new AbortController(); // cancels the in-flight load on unmount + + // Small sessionStorage helpers so the panels (memory preview, context files) + // paint instantly on a revisit and revalidate in the background. + function ssGet(key) { + try { + return JSON.parse(sessionStorage.getItem(key) || "null"); + } catch (_) { + return null; + } + } + function ssSet(key, val) { + try { + sessionStorage.setItem(key, JSON.stringify(val)); + } catch (_) {} + } if (!projectId) { ctx.navigate("/projects"); @@ -106,7 +122,17 @@ export function mount(root, params, ctx) { return; } list.forEach(function (c) { - rows.appendChild(convRow(c, { dialogs: ctx.dialogs, reload: loadChats })); + rows.appendChild( + convRow(c, { + dialogs: ctx.dialogs, + reload: loadChats, + // Soft-nav; convRow otherwise falls back to a full-page window.location + // (which also triggers the cross-document fade). + onOpen: function (conv) { + ctx.navigate("/c/" + encodeURIComponent(conv.id)); + }, + }), + ); }); } async function loadChats() { @@ -130,6 +156,14 @@ export function mount(root, params, ctx) { } // ---- memory panel ---- + var MEM_CACHE = "kloe:pmem:" + projectId; + // Paint the pin name + a hint line (cached preview, "Loading…", or an error). + function paintMem(pin, hint) { + var el = $("#memBody"); + el.innerHTML = ''; + el.querySelector(".pinname").textContent = pin; + el.querySelector(".lardhint").textContent = hint; + } async function renderMemory() { var el = $("#memBody"); var pin = project.lardProject; @@ -138,24 +172,32 @@ export function mount(root, params, ctx) { 'No memory pinned. Pin a lard project so this project’s chats read and record durable context.
'; return; } - el.innerHTML = 'Loading…
'; - el.querySelector(".pinname").textContent = pin; + // Instant paint from cache if it's for the same pin; else show Loading. + var cached = ssGet(MEM_CACHE); + paintMem(pin, cached && cached.pin === pin ? cached.hint : "Loading…"); try { - var r = await fetch("/api/lard/context?project=" + encodeURIComponent(pin)); + var r = await fetch("/api/lard/context?project=" + encodeURIComponent(pin), { + signal: abort.signal, + }); + var hint; if (!r.ok) { - el.querySelector(".lardhint").textContent = + hint = r.status === 409 ? "Connect lard in Settings to view its memory." : "Couldn’t load memory."; + paintMem(pin, hint); return; } var data = await r.json(); var preview = (data.area || data.profile || "").trim(); - el.querySelector(".lardhint").textContent = preview + hint = preview ? preview.slice(0, 240) + (preview.length > 240 ? "…" : "") : "No memory recorded yet."; + paintMem(pin, hint); + ssSet(MEM_CACHE, { pin: pin, hint: hint }); } catch (_) { - el.querySelector(".lardhint").textContent = "Couldn’t load memory."; + if (abort.signal.aborted) return; + paintMem(pin, "Couldn’t load memory."); } } @@ -232,17 +274,13 @@ export function mount(root, params, ctx) { }); // ---- context files ---- - async function renderContext() { + var CTX_CACHE = "kloe:pctx:" + projectId; + var ctxShown = null; + function paintCtxFiles(files) { + var j = JSON.stringify(files); + if (j === ctxShown) return; // unchanged revalidation — no rebuild, no flash + ctxShown = j; var el = $("#ctxBody"); - var files; - try { - files = - (await (await fetch("/api/projects/" + encodeURIComponent(projectId) + "/context")).json()) - .files || []; - } catch (_) { - el.innerHTML = 'Couldn’t load context files.
'; - return; - } el.innerHTML = ""; if (!files.length) { el.innerHTML = @@ -282,6 +320,27 @@ export function mount(root, params, ctx) { el.appendChild(card); }); } + async function renderContext() { + var cached = ssGet(CTX_CACHE); + if (cached) paintCtxFiles(cached); // instant paint on a revisit + var files; + try { + files = + ( + await ( + await fetch("/api/projects/" + encodeURIComponent(projectId) + "/context", { + signal: abort.signal, + }) + ).json() + ).files || []; + } catch (_) { + if (abort.signal.aborted) return; + if (!cached) $("#ctxBody").innerHTML = 'Couldn’t load context files.
'; + return; + } + ssSet(CTX_CACHE, files); + paintCtxFiles(files); + } $("#ctxAdd").addEventListener("click", function () { $("#fileInput").click(); }); @@ -398,34 +457,66 @@ export function mount(root, params, ctx) { ctx.navigate("/?new=1&project=" + encodeURIComponent(projectId)); }); - async function load() { - var res; + // The detail payload survives a navigation in sessionStorage so a revisit paints + // instantly instead of blanking for a round-trip; the fetch below revalidates. + var CACHE = "kloe:project:"; + function readCache() { try { - res = await fetch("/api/projects/" + encodeURIComponent(projectId)); + return JSON.parse(sessionStorage.getItem(CACHE + projectId) || "null"); } catch (_) { - return; + return null; } - if (!res.ok) { - ctx.navigate("/projects"); - return; - } - var data = await res.json(); + } + + // Paint the project header + chats (and refresh Memory, which depends on the + // project's pin). Deduped: an unchanged revalidation doesn't rebuild the DOM, + // so the instant cached paint doesn't flash a round-trip later. + var shownJson = null; + function paint(data) { + var j = JSON.stringify(data); + if (j === shownJson) return; + shownJson = j; project = data.project; document.title = project.name + " · Kloe"; $("#crumbname").textContent = project.name; $("#pname").textContent = project.name; $("#pdesc").textContent = project.description || ""; $("#pdesc").hidden = !project.description; - $("#detail").hidden = false; renderChats(data.conversations || []); renderMemory(); - renderContext(); } + async function load() { + var res; + try { + res = await fetch("/api/projects/" + encodeURIComponent(projectId), { signal: abort.signal }); + } catch (_) { + return; + } + if (!res.ok) { + ctx.navigate("/projects"); + return; + } + var data = await res.json(); + try { + sessionStorage.setItem(CACHE + projectId, JSON.stringify(data)); + } catch (_) {} + paint(data); + } + + // Reveal the structure right away with a loading state (so a soft-nav doesn't + // show a blank pane), then paint cache instantly if we have it. Context files + // depend only on the id, so fetch them in parallel with the main payload. + $("#detail").hidden = false; + $("#rows").innerHTML = '