diff --git a/server.ts b/server.ts index 1325aa3..282608f 100644 --- a/server.ts +++ b/server.ts @@ -191,7 +191,7 @@ if (import.meta.main) { "/c/:id": page("index.html"), // deep link to a conversation — the SPA reads the id from the path "/settings": page("settings.html"), "/conversations": page("conversations.html"), - "/projects": page("projects.html"), + "/projects": page("index.html"), // SPA route — the shell's router mounts the projects view "/p/:id": page("project.html"), // project detail — the page reads the id from the path // Auth (indiko OAuth). Inert unless auth.enabled — the SPA only navigates // here after a 401. /client-metadata.json is the public client document. diff --git a/src/assets.ts b/src/assets.ts index 1cd35c7..433f3aa 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -16,13 +16,10 @@ import { brotliCompressSync, gzipSync, constants as zlib } from "node:zlib"; const CLIENT_DIR = new URL("./client/", import.meta.url).pathname; -const ENTRY_HTML = [ - "index.html", - "conversations.html", - "settings.html", - "projects.html", - "project.html", -]; +// projects.html is gone — /projects is served by the shell (index.html) and its +// router mounts src/client/views/projects.js. The other satellites are still +// their own documents until they're converted too. +const ENTRY_HTML = ["index.html", "conversations.html", "settings.html", "project.html"]; /** A built output ready to serve: raw bytes plus precomputed encodings and its ETag. */ export interface ServedAsset { diff --git a/src/client/app.css b/src/client/app.css index d46cc8d..938dd07 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -837,6 +837,23 @@ body.selecting #selectBtn { min-height: 0; min-width: 0; } +/* The chat view and the router's satellite outlet are the two panes inside .main. + Each fills it and is itself a column, so .scroll / .chatscroll keep their + flex:1 overflow behavior. Exactly one shows at a time; the [hidden] rules must + be explicit because an author `display:flex` outranks the `hidden` attribute's + UA `display:none`. */ +.chatshell, +.viewoutlet { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + min-width: 0; +} +.chatshell[hidden], +.viewoutlet[hidden] { + display: none; +} /* Fixed header height so it never grows/shifts when the collapsed-sidebar toggle appears in it. */ .head { diff --git a/src/client/app.js b/src/client/app.js index 5662153..19d8228 100644 --- a/src/client/app.js +++ b/src/client/app.js @@ -32,6 +32,7 @@ import { TOOL_ICON as ICON_TOOL, SEND_ICON as SEND, } from "./icons.js"; +import { createRouter } from "./router.js"; import { mountSidebar } from "./sidebar.js"; (function () { @@ -1734,11 +1735,17 @@ import { mountSidebar } from "./sidebar.js"; var conversations = []; var dialogs = mountDialogs(); var sidebar = mountSidebar({ - onSelect: function (id, t) { - selectConversation(id, t); + // 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). + onSelect: function (id) { + router.navigate("/c/" + encodeURIComponent(id)); }, onNew: function () { - newConversation(); + // "New chat" always yields a fresh conversation. Already home → reset in + // place; otherwise navigate home, where enterChat starts a fresh one. + if (location.pathname === "/" && !location.search) newConversation("none"); + else router.navigate("/"); }, activeId: function () { return convId; @@ -1849,16 +1856,81 @@ import { mountSidebar } from "./sidebar.js"; input.focus(); if (nav !== "none") setUrl("/", nav === "replace"); } - window.addEventListener("popstate", function () { - var id = convIdFromPath(); - if (id) { + // Enter (or re-enter) the chat view for a resolved route. The router owns the + // URL, so nav mode is always "none" here — we only open the right conversation. + // Called on click-nav to chat, on popstate landing on chat, and at boot. + function enterChat(params) { + if (params && params.isNew) { + newConversation("none"); + return; + } + if (params && params.id) { var c = conversations.find(function (x) { - return x.id === id; + return x.id === params.id; }); - selectConversation(id, c ? c.title : null, "none"); - } else { - newConversation("none"); + selectConversation(params.id, c ? c.title : null, "none"); + return; } + newConversation("none"); // bare "/" — a fresh chat, not the last conversation + } + + // The route table. Chat ("/", "/c/:id", legacy "/?c=" and "/?new") is the + // persistent home; /projects is a mounted view. Everything else returns null so + // the router lets the browser do a real navigation (not yet SPA-converted). + function resolveRoute(path) { + var u = new URL(path, location.origin); + var q = new URLSearchParams(u.search); + if (u.pathname === "/") { + if (q.has("new")) return { kind: "chat", nav: "chat", params: { isNew: true } }; + if (q.get("c")) return { kind: "chat", nav: "chat", params: { id: q.get("c") } }; + return { kind: "chat", nav: "chat", params: {} }; + } + var m = u.pathname.match(/^\/c\/([^/]+)\/?$/); + if (m) return { kind: "chat", nav: "chat", params: { id: decodeURIComponent(m[1]) } }; + if (u.pathname === "/projects") { + return { + kind: "view", + nav: "projects", + params: {}, + load: function () { + return import("./views/projects.js"); + }, + }; + } + return null; + } + + // Light up the rail row for the current section (the MPA baked this into each + // 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) { + document.getElementById("chatsBtn").classList.toggle("active", section === "conversations"); + document.getElementById("projectsBtn").classList.toggle("active", section === "projects"); + } + + // Shared context handed to mounted views: the live sidebar, the dialog helpers, + // soft navigation, and the rail toggle (each view wires its own header button). + var viewCtx = { + sidebar: sidebar, + dialogs: dialogs, + navigate: function (path) { + router.navigate(path); + }, + toggleRail: function () { + sidebar.toggleRail(); + }, + }; + var router = createRouter({ + outlet: document.getElementById("viewOutlet"), + chatShell: document.getElementById("chatShell"), + resolve: resolveRoute, + enterChat: enterChat, + onNav: setActiveNav, + ctx: viewCtx, + // Instant swap. The content paints synchronously from cache, so a crossfade + // is pure perceived latency on top of an already-there view — a snap reads as + // faster. (The cross-document @view-transition still smooths real page loads.) + transition: false, }); // ---- model picker ------------------------------------------------------ @@ -2061,16 +2133,12 @@ import { mountSidebar } from "./sidebar.js"; .catch(function () {}); } - if (params.has("new")) { - newConversation("replace"); - } else if (wantId) { - var c = conversations.find(function (x) { - return x.id === wantId; - }); - selectConversation(wantId, c ? c.title : null, "replace"); - } else { - newConversation("replace"); // land on a fresh chat, not the last conversation - } + // Normalize a "?new" landing to "/" (drop the query so a refresh and the + // "New chat" home-check both see a canonical fresh chat); the project, if any, + // was already captured in pendingProject. Then hand off to the router, which + // enters chat or mounts the matching view for whatever URL we loaded on. + if (params.has("new")) history.replaceState({}, "", "/"); + router.start(location.pathname + location.search); updateSend(); })(); })(); diff --git a/src/client/index.html b/src/client/index.html index ba42812..02ad2ce 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -51,6 +51,11 @@
+ +
+ + + diff --git a/src/client/router.js b/src/client/router.js new file mode 100644 index 0000000..fef7e3d --- /dev/null +++ b/src/client/router.js @@ -0,0 +1,121 @@ +/* + * Client-side router for the unified shell. One document, no full-page reloads + * between sections. The chat view is the persistent home — its DOM and live SSE + * stream stay mounted in #chatShell — while satellite routes (Conversations, + * Projects, a project, Settings) mount a view module into #viewOutlet and unmount + * on the way out. + * + * A route resolves to one of: + * { kind: "chat", params } — show #chatShell, hand params to enterChat + * { kind: "view", load, params } — hide it, mount load()'s module in the outlet + * null — not a route we own; let the browser navigate + * + * `load` returns a promise of a module exporting `mount(root, params) -> { destroy? }`. + * Unowned routes (null) fall through to a real navigation, so half-converted apps + * still work: a link to a not-yet-SPA'd page just does a normal load. + */ + +// A left click with no modifier on a plain, same-tab link — the only kind we +// intercept. Everything else (new-tab, download, external, middle-click) is left +// to the browser. +function isPlainClick(e, a) { + return ( + !e.defaultPrevented && + e.button === 0 && + !e.metaKey && + !e.ctrlKey && + !e.shiftKey && + !e.altKey && + a.target !== "_blank" && + !a.hasAttribute("download") + ); +} + +export function createRouter(opts) { + var outlet = opts.outlet; + var chatShell = opts.chatShell; + var resolve = opts.resolve; // (path) -> route | null + var enterChat = opts.enterChat; // (params) -> void + var current = null; // the mounted satellite view's { destroy? }, or null on chat + + function teardown() { + if (current && typeof current.destroy === "function") { + try { + current.destroy(); + } catch (_) {} + } + current = null; + } + + async function apply(route) { + teardown(); + if (opts.onNav) opts.onNav(route.nav); // reflect the destination in the rail + if (route.kind === "chat") { + outlet.hidden = true; + outlet.replaceChildren(); + chatShell.hidden = false; + enterChat(route.params); + return; + } + // A satellite view: give it a clean outlet and hide the chat (still live). + chatShell.hidden = true; + outlet.replaceChildren(); + outlet.hidden = false; + var mod = await route.load(); + current = (await mod.mount(outlet, route.params, opts.ctx)) || {}; + } + + // history: "push" (a user navigation), "replace" (normalize without a new + // entry), or "none" (we arrived from popstate/boot; the URL is already right). + async function go(path, history) { + var route = resolve(path); + if (!route) { + window.location.assign(path); + return; + } + if (history === "push") window.history.pushState({}, "", path); + else if (history === "replace") window.history.replaceState({}, "", path); + + if (opts.transition && document.startViewTransition) { + try { + await document.startViewTransition(function () { + return apply(route); + }).finished; + } catch (_) { + /* a superseded transition rejects; the newer nav already applied */ + } + } else { + await apply(route); + } + } + + document.addEventListener("click", function (e) { + var t = e.target; + var a = t && t.closest ? t.closest("a[href]") : null; + if (!a || !isPlainClick(e, a)) return; + var url = new URL(a.getAttribute("href"), window.location.href); + if (url.origin !== window.location.origin) return; + var path = url.pathname + url.search; + if (!resolve(path)) return; // not ours — let the browser do a real load + e.preventDefault(); + if (path === window.location.pathname + window.location.search) return; // already here + go(path, "push"); + }); + + window.addEventListener("popstate", function () { + go(window.location.pathname + window.location.search, "none"); + }); + + return { + // Programmatic navigation (sidebar recents, "New chat", in-view links that + // prefer JS over an ). Same semantics as clicking a link. + navigate: function (path) { + if (path === window.location.pathname + window.location.search) return; + go(path, "push"); + }, + // Initial route at boot: enter without pushing a history entry. + start: function (path) { + return go(path, "none"); + }, + }; +} diff --git a/src/client/sidebar.js b/src/client/sidebar.js index 296507f..a6a05c4 100644 --- a/src/client/sidebar.js +++ b/src/client/sidebar.js @@ -137,5 +137,5 @@ export function mountSidebar(config) { var cached = readRecents(); if (cached && cached.length) render(cached); - return { render: render, closeRail: closeRail }; + return { render: render, closeRail: closeRail, toggleRail: toggleRail }; } diff --git a/src/client/views/projects.js b/src/client/views/projects.js new file mode 100644 index 0000000..30d5507 --- /dev/null +++ b/src/client/views/projects.js @@ -0,0 +1,137 @@ +/* + * Projects gallery, as a router view. A grid of project cards (name, description, + * last-active), a "New project" action, and click-through to /p/. Mounted into + * the shell's #viewOutlet by router.js — the sidebar, dialogs, and auth are already + * up (the shell owns them), so this view only renders its own main region. + * + * Converted from the standalone projects.js: markup is client-owned (rendered into + * `root` here rather than shipped as a separate HTML document), DOM lookups are + * scoped to `root`, navigation goes through ctx.navigate (soft), and in-flight + * fetches are abandoned on destroy. + */ + +// The view's main region. IDs are looked up scoped to `root`, so the chat view's +// hidden-but-present #menu never collides with this one. +var TEMPLATE = + '
' + + '" + + 'Projects' + + '
' + + "
" + + '
' + + '
Loading…
' + + "
"; + +var LIST_CACHE = "kloe:projects"; +function readCache() { + try { + return JSON.parse(sessionStorage.getItem(LIST_CACHE) || "null"); + } catch (_) { + return null; + } +} +function writeCache(list) { + try { + sessionStorage.setItem(LIST_CACHE, JSON.stringify(list || [])); + } catch (_) {} +} + +function fmtDate(ms) { + var d = new Date(ms), + now = new Date(); + var startOfDay = function (x) { + return new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); + }; + var days = Math.round((startOfDay(now) - startOfDay(d)) / 86400000); + if (days === 0) return "Today"; + if (days === 1) return "Yesterday"; + var opts = { month: "short", day: "numeric" }; + if (d.getFullYear() !== now.getFullYear()) opts.year = "numeric"; + return d.toLocaleDateString(undefined, opts); +} + +export function mount(root, _params, ctx) { + root.innerHTML = TEMPLATE; + var grid = root.querySelector("[data-grid]"); + var abort = new AbortController(); + + root.querySelector("[data-menu]").addEventListener("click", function () { + ctx.toggleRail(); + }); + + function render(list) { + grid.innerHTML = ""; + if (!list.length) { + grid.innerHTML = + '
No projects yet. Create one to group chats and pin shared memory.
'; + return; + } + list.forEach(function (p) { + var card = document.createElement("a"); + card.className = "projcard"; + card.href = "/p/" + encodeURIComponent(p.id); + var name = document.createElement("div"); + name.className = "projcardname"; + name.textContent = p.name; + card.appendChild(name); + if (p.description) { + var d = document.createElement("div"); + d.className = "projcarddesc"; + d.textContent = p.description; + card.appendChild(d); + } + var meta = document.createElement("div"); + meta.className = "projcardmeta"; + var count = p.chatCount ? " · " + p.chatCount + " chat" + (p.chatCount > 1 ? "s" : "") : ""; + meta.textContent = fmtDate(p.updatedAt) + count; + card.appendChild(meta); + grid.appendChild(card); + }); + } + + async function load() { + try { + var res = await fetch("/api/projects", { signal: abort.signal }); + var list = (await res.json()).projects || []; + render(list); + writeCache(list); + } catch (_) { + if (abort.signal.aborted) return; // navigated away — leave the DOM alone + if (!grid.children.length) + grid.innerHTML = '
Failed to load projects
'; + } + } + + root.querySelector("[data-new]").addEventListener("click", async function () { + var name = await ctx.dialogs.prompt({ + title: "New project", + placeholder: "Project name", + ok: "Create", + }); + if (!name || !name.trim()) return; + try { + var r = await fetch("/api/projects", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: name.trim() }), + }); + var j = await r.json(); + if (j.id) ctx.navigate("/p/" + encodeURIComponent(j.id)); + } catch (_) { + /* stay put on failure */ + } + }); + + // Paint cached cards immediately (instant view swap), then revalidate. + var cached = readCache(); + if (cached && cached.length) render(cached); + load(); + + return { + destroy: function () { + abort.abort(); + }, + }; +}