Compare commits

..
Author SHA1 Message Date
teahubxyzandClaude Opus 5.5 91086a8431 havre: the hide button, and the chat full width when the list is hidden
ci / test (pull_request) Successful in 4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjyFZEJivqQMhhCyyQau49
2026-10-23 20:18:39 +00:00
teahubxyzandClaude Opus 5.5 1157892001 havre: hide the list entirely, like a sidebar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjyFZEJivqQMhhCyyQau49
2026-10-23 20:18:29 +00:00
teahubxyzandClaude Opus 5.5 5ca4460d79 Havre: drag the divider between the list and the chat (#251)
Havre: drag the divider between the list and the chat (#251)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjyFZEJivqQMhhCyyQau49
2026-10-23 19:47:05 +00:00
teahubxyzandClaude Opus 5.5 d821cc5bee Havre: sort the model picker by price (#250)
Havre: sort the model picker by price (#250)

The model picker gets a sort beside its search: name (the one in use first, then A to Z), cheapest first, priciest first, kept in this browser. Sorted by price, the price column says which way; a text model goes by its price in, then out. Clips, which Venice prices one at a time, have no sort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjyFZEJivqQMhhCyyQau49
2026-10-23 19:29:44 +00:00
4 changed files with 100 additions and 1 deletions

No files matched your search

+1
View File
@@ -24,6 +24,7 @@
<link rel="stylesheet" href="style.css">
<script type="module" src="app.js"></script>
<script src="update.js" defer></script>
<script src="split.js" defer></script>
</head>
<body>
<a class="skip" href="#chat">skip to the chat</a>
+84
View File
@@ -0,0 +1,84 @@
// split.js: on a wide screen, the line between the list (chats / characters / harbor) and the chat can be dragged, or
// moved with the arrow keys once focused. Double-click (or Home) puts it back. The list can also be hidden, like a
// sidebar: the ‹ button on the line, dragging it most of the way to the left, or Ctrl/Cmd+B; › brings it back.
// The width and whether it's hidden are kept in this browser. Below 1040px the two take turns on screen, so there is
// nothing to drag and the divider hides (style.css).
(() => {
const KEY = "havre.split", HID = "havre.split.hidden", MIN = 320, CHAT_MIN = 380, SNAP = 200;
const get = (k) => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } };
const put = (k, v) => { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, JSON.stringify(v)); } catch { /* not kept */ } };
function start() {
const studio = document.getElementById("studio"), chars = document.getElementById("chars"), chat = document.getElementById("chat");
if (!studio || !chars || !chat) return;
const bar = document.createElement("div");
bar.className = "splitter";
bar.tabIndex = 0;
bar.setAttribute("role", "separator");
bar.setAttribute("aria-orientation", "vertical");
bar.setAttribute("aria-controls", "chars");
bar.setAttribute("aria-label", "Resize the list and the chat");
bar.title = "drag to resize · double-click to reset";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "splitbtn";
bar.append(btn);
chat.before(bar);
const wide = () => window.matchMedia("(min-width: 1041px)").matches;
const clamp = (w) => Math.round(Math.max(MIN, Math.min(w, studio.clientWidth - CHAT_MIN - bar.offsetWidth)));
let hidden = get(HID) === true;
function show() {
const off = hidden && wide();
studio.classList.toggle("list-hidden", off);
btn.textContent = off ? "›" : "‹";
btn.setAttribute("aria-label", off ? "Show the list" : "Hide the list");
btn.title = off ? "show the list (ctrl+b)" : "hide the list (ctrl+b)";
btn.setAttribute("aria-expanded", off ? "false" : "true");
}
function hide(on) { hidden = on; put(HID, on || null); show(); }
function set(w, keep = true) {
if (w == null || !wide()) { chars.style.flex = ""; bar.removeAttribute("aria-valuenow"); if (w == null && keep) put(KEY, null); return; }
w = clamp(w);
chars.style.flex = `0 0 ${w}px`;
bar.setAttribute("aria-valuenow", String(w));
if (keep) put(KEY, w);
}
set(get(KEY), false);
show();
window.addEventListener("resize", () => { set(get(KEY), false); show(); });
btn.addEventListener("pointerdown", (e) => e.stopPropagation());
btn.addEventListener("dblclick", (e) => e.stopPropagation());
btn.addEventListener("click", () => hide(!hidden));
bar.addEventListener("pointerdown", (e) => {
if (!wide() || e.button !== 0) return;
e.preventDefault();
bar.setPointerCapture(e.pointerId);
const left = studio.getBoundingClientRect().left;
document.body.classList.add("splitting");
const move = (ev) => {
const w = ev.clientX - left;
if (w < SNAP) { if (!hidden) hide(true); return; } // most of the way left: tuck it away
if (hidden) hide(false);
set(w);
};
const up = () => { bar.removeEventListener("pointermove", move); bar.removeEventListener("pointerup", up); bar.removeEventListener("pointercancel", up); document.body.classList.remove("splitting"); };
bar.addEventListener("pointermove", move);
bar.addEventListener("pointerup", up);
bar.addEventListener("pointercancel", up);
});
bar.addEventListener("dblclick", () => { hide(false); set(null); });
bar.addEventListener("keydown", (e) => {
if (e.target !== bar) return;
const now = chars.getBoundingClientRect().width, step = e.shiftKey ? 80 : 20;
if (e.key === "ArrowLeft") { if (hidden) return e.preventDefault(); if (now - step < MIN) hide(true); else set(now - step); }
else if (e.key === "ArrowRight") { if (hidden) hide(false); else set(now + step); }
else if (e.key === "Home") { hide(false); set(null); }
else if (e.key === "Enter" || e.key === " ") hide(!hidden);
else return;
e.preventDefault();
});
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "b" && wide()) { e.preventDefault(); hide(!hidden); }
});
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start();
})();
+14
View File
@@ -144,6 +144,19 @@ input:disabled, textarea:disabled, select:disabled { background: var(--well); co
/* ---------- the studio: characters | chat ---------- */
.studio { display: flex; flex-wrap: wrap; align-items: flex-start; border-bottom: 2px solid var(--ink); }
.chars { flex: 1.15 1 560px; min-width: 0; align-self: stretch; border-right: 1px solid var(--ink); }
/* the line between the list and the chat, to drag (split.js) */
.splitter { flex: 0 0 7px; align-self: stretch; margin-left: -4px; margin-right: -3px; position: relative; z-index: 2; cursor: col-resize; touch-action: none; }
.splitter:hover, .splitter:focus-visible, .splitting .splitter { background: var(--hi); outline: none; }
body.splitting { cursor: col-resize; user-select: none; }
.splitbtn {
position: sticky; top: 12px; left: 0; margin: 12px 0 0 -9px; width: 26px; height: 32px; padding: 0;
border: 1px solid var(--ink); border-radius: 0; background: var(--paper); color: var(--ink); font: 700 16px/1 var(--mono); cursor: pointer;
}
.splitbtn:hover, .splitbtn:focus-visible { background: var(--hi); }
/* the list hidden, like a sidebar: the chat takes the width, the line stays to bring it back */
.studio.list-hidden .chars { display: none; }
.studio.list-hidden .splitter { flex-basis: 30px; margin: 0; border-right: 1px solid var(--ink); }
.studio.list-hidden .splitbtn { margin-left: 1px; }
.chat {
flex: 1 1 440px; min-width: 0; display: flex; flex-direction: column;
position: sticky; top: 0; max-height: 100vh;
@@ -504,6 +517,7 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
/* ---------- narrow screens ---------- */
@media (max-width: 1040px) {
.splitter { display: none; }
.chars { border-right: 0; border-bottom: 1px solid var(--ink); }
/* one at a time, like a phone's messages: the list, or the chat. In the chat the left column's tabs stay above it,
without their panels, so CHATS / CHARACTERS / HARBOR are always one tap away */
+1 -1
View File
@@ -4,7 +4,7 @@
// Checking fetches with cache: "reload", which also refreshes the browser's copies, so the reload gets the new files.
(() => {
const FILES = ["./", "app.js", "style.css", "workflows.js", "abilities.js", "card.js", "venice.js", "memory.js",
"threads.js", "media.js", "backup.js", "theme.js"];
"threads.js", "media.js", "backup.js", "theme.js", "split.js"];
let base = null, shown = false, busy = false;
async function print() {
const parts = await Promise.all(FILES.map((f) => fetch(f, { cache: "reload" }).then((r) => (r.ok ? r.text() : "")).catch(() => null)));