Compare commits

..
Author SHA1 Message Date
teahubxyz 4eb0d3d4c5 Uncensor Haven: Change default safe mode from true to false 2026-10-23 21:50:57 +00:00
teahubxyzandClaude Opus 5.5 7eaf3afb81 Havre: hide the list like a sidebar (#252)
Havre: hide the list like a sidebar (#252)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjyFZEJivqQMhhCyyQau49
2026-10-23 20:36:04 +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
5 changed files with 101 additions and 2 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)));
+1 -1
View File
@@ -37,7 +37,7 @@ function sent(e, what) {
}
function explain(status, said, refused) {
// Venice's own words for it ("violates the content policy of Venice.ai or the model provider") say no more than this
if (refused) return "Venice turned that down as against its content rules" + (/content polic/i.test(said) || !said ? "." : ` (“${said.replace(/[.\s]+$/, "")}”).`);
if (refused) return "Venice turned that down as against its content rules" + (/content polic/i.test(said) || !said ? "." : ` ("${said.replace(/[.\s]+$/, "")}").`);
if (status === 401 || status === 403) return "Venice didn't accept that key. Check it at venice.ai → Settings → API.";
if (status === 402) return "Venice says the key is out of credit.";
if (status === 429) return "Venice is rate-limiting this key. Wait a moment and try again.";