make some overall QoL improvements

This commit is contained in:
Frederik Spirgi
2026-09-30 11:05:13 +02:00
parent d15f7867d5
commit c69070a6e6
18 changed files with 256 additions and 78 deletions
+8 -6
View File
@@ -221,12 +221,12 @@
data-chat-action="personaState">Roleplay state</button>
<button id="backupWorkspace"
class="secondary-action chat-drawer-action"
type="button">Backup workspace</button>
type="button">Download workspace archive</button>
<button id="importWorkspace"
class="secondary-action chat-drawer-action"
type="button">Import workspace</button>
<input id="importWorkspaceFile" class="hidden" type="file"
accept="application/json,.json" />
accept="application/json,.json,.tar.gz,application/gzip" />
</div>
</details>
<details class="chat-drawer-section chat-drawer-danger">
@@ -316,7 +316,7 @@
</div>
<div id="personaModal" class="modal-overlay side-panel-overlay hidden">
<div class="modal-card persona-modal-card side-panel-card">
<div class="modal-card persona-modal-card side-panel-card" role="dialog" aria-modal="true" aria-labelledby="personaFormTitle" tabindex="-1">
<div class="modal-header">
<div class="persona-modal-heading">
<p class="eyebrow">Persona editor</p>
@@ -380,7 +380,7 @@
</div>
<div id="roleplayStarterModal" class="modal-overlay side-panel-overlay hidden">
<div class="modal-card roleplay-starter-card side-panel-card">
<div class="modal-card roleplay-starter-card side-panel-card" role="dialog" aria-modal="true" aria-labelledby="roleplayStarterTitle" tabindex="-1">
<div class="modal-header">
<div class="persona-modal-heading">
<p class="eyebrow">Start roleplay</p>
@@ -449,7 +449,7 @@
</div>
<div id="onboardingModal" class="modal-overlay hidden">
<div class="modal-card onboarding-card">
<div class="modal-card onboarding-card" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle" aria-describedby="onboardingSubtitle" tabindex="-1">
<div class="modal-header">
<div class="persona-modal-heading">
<p class="eyebrow">Get started</p>
@@ -466,7 +466,9 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.12/marked.min.js"
integrity="sha512-rCQgmUulW6f6QegOvTntKKb5IAoxTpGVCdWqYjkXEpzAns6XUFs8NKVqWe+KQpctp/EoRSFSuykVputqknLYMg=="
crossorigin="anonymous" defer></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js" defer></script>
<script type="module" src="/js/script.js?v=20260930-chat-usability"></script>
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
+17 -10
View File
@@ -5,6 +5,7 @@ import { get, post, put } from "./api.js?v=20260909-account-controls";
import { initializeAppearance } from "./themeController.js?v=20260909-account-controls";
import { clearStatus, setStatus } from "./statusNotice.js?v=20260909-account-controls";
import { populatePersonaForm, readPersonaForm } from "./personaForm.js?v=20260909-account-controls";
import { openModal, closeModal, topModal } from "./modalFocus.js";
const $ = (id) => document.getElementById(id);
const marketPersonaList = $("marketPersonaList");
@@ -31,7 +32,6 @@ const marketPreviewExampleDialogues = $("marketPreviewExampleDialogues");
const marketPreviewIntro = $("marketPreviewIntro");
const marketPreviewUserPersonaField = $("marketPreviewUserPersonaField");
const marketPreviewUserPersonaSelect = $("marketPreviewUserPersonaSelect");
const marketPreviewScenarioField = $("marketPreviewScenarioField");
const marketPreviewScenario = $("marketPreviewScenario");
const marketPreviewStepOne = $("marketPreviewStepOne");
const marketPreviewStepTwo = $("marketPreviewStepTwo");
@@ -74,7 +74,6 @@ const marketPersonaFields = {
const validMarketSorts = new Set(["best", "newest", "most_favorited", "most_popular", "top_rated", "alphabetical"]);
let marketPersonasByType = { assistant: [], user: [] };
let assistantPersonas = [];
let userPersonas = [];
let pendingMarketPersona = null;
let editingPersonaId = null;
@@ -90,7 +89,7 @@ function setMarketStatus(message, state = "") {
}
function closeMarketPopup(value = null) {
marketPopupModal.classList.add("hidden");
closeModal(marketPopupModal);
if (marketPopupResolver) {
const resolve = marketPopupResolver;
marketPopupResolver = null;
@@ -115,7 +114,7 @@ function promptMarketPopup({
marketPopupInput.placeholder = placeholder;
marketPopupConfirm.textContent = confirmLabel;
marketPopupField.classList.remove("hidden");
marketPopupModal.classList.remove("hidden");
openModal(marketPopupModal, marketPopupInput);
marketPopupInput.focus();
marketPopupInput.select();
return new Promise((resolve) => {
@@ -241,7 +240,7 @@ function setCollapsiblePreviewText(element, value, maxLength = 220) {
}
function closeMarketPreview() {
marketPreviewModal.classList.add("hidden");
closeModal(marketPreviewModal);
pendingMarketPersona = null;
marketPreviewStep = 1;
marketPreviewUserPersonaSelect.innerHTML = "";
@@ -318,7 +317,7 @@ function openMarketPreview(persona) {
marketPreviewConfirm.classList.remove("hidden");
}
renderMarketPreviewStep();
marketPreviewModal.classList.remove("hidden");
openModal(marketPreviewModal, isAssistant ? marketPreviewUserPersonaSelect : marketPreviewConfirm);
}
function openPersonaForm(persona = null) {
@@ -326,12 +325,11 @@ function openPersonaForm(persona = null) {
marketPersonaFormTitle.textContent = persona ? "Edit AI Character" : "Create AI Character";
populatePersonaForm(marketPersonaFields, persona || {});
setPersonaFormNotice("");
marketPersonaModal.classList.remove("hidden");
marketPersonaName.focus();
openModal(marketPersonaModal, marketPersonaName);
}
function closePersonaForm() {
marketPersonaModal.classList.add("hidden");
closeModal(marketPersonaModal);
editingPersonaId = null;
setPersonaFormNotice("");
}
@@ -510,7 +508,6 @@ async function reportPersona(marketId) {
async function loadOwnedPersonas() {
const res = await get("/personas");
if (res.error) return false;
assistantPersonas = res.assistantPersonas || [];
userPersonas = res.userPersonas || [];
activeUserPersonaId = res.activeUserPersonaId || null;
return true;
@@ -608,9 +605,19 @@ marketPopupInput.addEventListener("keydown", (event) => {
}
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
closeMarketPopup(null);
}
});
document.addEventListener("keydown", event => {
if (event.key !== "Escape") return;
const modal = topModal();
if (!modal) return;
event.preventDefault();
if (modal === marketPopupModal) closeMarketPopup(null);
else if (modal === marketPersonaModal) closePersonaForm();
else if (modal === marketPreviewModal) closeMarketPreview();
});
marketSearchInput.addEventListener("input", applyMarketFilter);
if (marketAssistantSort) {
marketAssistantSort.addEventListener("change", () => setMarketSort("assistant", marketAssistantSort.value));
+56 -35
View File
@@ -7,6 +7,7 @@ import { appendSources, createWebSearchToggle } from "./webSearch.js";
import { appendAttachments, createAttachmentComposer } from "./attachments.js?v=20260930-chat-usability";
import { createReasoningToggle, finishReasoning, updateReasoning } from "./reasoning.js?v=20260921-reasoning-capability";
import { closeChatDrawer, openChatDrawer } from "./chatDrawer.js?v=20260909-account-controls";
import { openModal, closeModal, topModal } from "./modalFocus.js";
import { onboardingPrompts, requestedChatId } from "./constants.js?v=20260909-account-controls";
import { getModelProfile } from "./modelProfiles.js?v=20260909-account-controls";
import { applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode, readStoredAppearance } from "./themeController.js?v=20260909-account-controls";
@@ -243,7 +244,7 @@ function markOnboardingCompleted() {
function closeOnboarding() {
if (!onboardingModal) return;
onboardingModal.classList.add("hidden");
closeModal(onboardingModal);
}
function renderOnboardingStep() {
@@ -262,6 +263,7 @@ function renderOnboardingStep() {
button.addEventListener("click", () => {
onboardingIntent = key;
renderOnboardingStep();
onboardingChoices.querySelector(".active")?.focus();
});
onboardingChoices.appendChild(button);
});
@@ -299,7 +301,7 @@ function maybeShowOnboarding() {
onboardingStep = 1;
onboardingIntent = "ask";
renderOnboardingStep();
onboardingModal.classList.remove("hidden");
openModal(onboardingModal, onboardingChoices.querySelector("button"));
}
function setMessageContent(el, content) {
const markdownParser = window.marked;
@@ -311,11 +313,6 @@ function setMessageContent(el, content) {
}
}
function getChatById(id) { return chatSessions.find((chat) => chat.id === id); }
function summarizeText(value, limit = 96) {
const normalized = String(value || "").replace(/\s+/g, " ").trim();
if (!normalized) return "";
return normalized.length > limit ? `${normalized.slice(0, limit - 3).trimEnd()}...` : normalized;
}
function getPersonaName(id, items) {
return items.find((persona) => persona.id === id)?.name || "";
}
@@ -434,10 +431,6 @@ function updateJumpToLatest() {
messagesDiv.addEventListener("scroll", updateJumpToLatest, {passive: true});
jumpToLatest.addEventListener("click", scrollMessagesToBottom);
function getNewestMessage() {
return currentMessages.length ? currentMessages[currentMessages.length - 1] : null;
}
function getMessageIndexById(messageId) {
return currentMessages.findIndex((message) => message.id === messageId);
}
@@ -505,15 +498,6 @@ function syncRetryStateFromMessage(message) {
return state;
}
function ensureRetryState(message) {
if (!message?.id) return null;
let state = messageRetryState.get(message.id);
if (!state) {
state = syncRetryStateFromMessage(message);
}
return state;
}
async function openCreatedChat(chat) {
if (!chat?.id) throw new Error("The server did not return the newly created chat");
if (chatSearchInput) {
@@ -587,7 +571,7 @@ async function editChatMessage(messageId, fallbackIndex = -1) {
async function deleteChatMessage(messageId, fallbackIndex = -1) {
const target = await resolveMessageTarget(messageId, fallbackIndex);
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
const { index, message } = target;
const { message } = target;
const confirmed = await confirmPopup({
eyebrow: message.role === "user" ? "Your message" : "Assistant message",
title: "Delete message",
@@ -1246,7 +1230,7 @@ function updateActivePersonaStatus(items, activeId, statusEl, emptyText, prefix)
function openPersonaForm(persona = null, personaType = "assistant") {
closeChatDrawer();
closePopup(false);
personaModal.classList.remove("hidden");
openModal(personaModal, personaNameInput);
if (persona) {
editingPersonaId = persona.id; editingPersonaType = persona.persona_type || "assistant";
personaFormTitle.textContent = `Edit ${editingPersonaType === "user" ? "your persona" : "AI Character"}`;
@@ -1266,7 +1250,7 @@ function openPersonaForm(persona = null, personaType = "assistant") {
personaNameInput.focus();
}
function closePersonaForm() { personaModal.classList.add("hidden"); editingPersonaId = null; editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); }
function closePersonaForm() { closeModal(personaModal); editingPersonaId = null; editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); }
function populateRoleplayStarter() {
if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return;
roleplayCharacterSelect.innerHTML = "";
@@ -1315,12 +1299,11 @@ function openRoleplayStarter(characterId = null) {
populateRoleplayStarter();
if (roleplayScenarioInput) roleplayScenarioInput.value = "";
setRoleplayStarterNotice("");
roleplayStarterModal.classList.remove("hidden");
if (roleplayCharacterSelect.options.length) roleplayCharacterSelect.focus();
openModal(roleplayStarterModal, roleplayCharacterSelect);
}
function closeRoleplayStarter() {
if (!roleplayStarterModal) return;
roleplayStarterModal.classList.add("hidden");
closeModal(roleplayStarterModal);
roleplayPresetCharacterId = null;
roleplayStarterStep = 1;
roleplayStarterConfirm.disabled = false;
@@ -1349,7 +1332,7 @@ function finishPopup(result) {
}
function closePopup(resolveValue = null) {
if (!popupModal || popupModal.classList.contains("hidden")) return;
popupModal.classList.add("hidden");
closeModal(popupModal);
popupField.classList.add("hidden");
popupInput.value = "";
popupConfirmBtn.disabled = false;
@@ -1377,7 +1360,7 @@ function openPopup(options) {
} else {
popupInput.value = "";
}
popupModal.classList.remove("hidden");
openModal(popupModal, options.mode === "prompt" ? popupInput : popupConfirmBtn);
return new Promise((resolve) => {
popupResolver = resolve;
requestAnimationFrame(() => {
@@ -1801,18 +1784,18 @@ function exportCurrentChat() {
}
async function backupWorkspace() {
const response = await fetch("/exports/workspace");
const data = await response.json();
const blob = new Blob([JSON.stringify(data.workspace, null, 2)], { type: "application/json;charset=utf-8" });
const response = await fetch("/exports/workspace/archive");
if (!response.ok) return setNotice("Workspace archive could not be created.", "error");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.json`;
link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setNotice("Workspace backup downloaded.", "success");
setNotice("Workspace archive downloaded with images.", "success");
}
function formatImportPreview(preview, warnings = []) {
@@ -1829,6 +1812,31 @@ function formatImportPreview(preview, warnings = []) {
async function importWorkspaceFromFile(file) {
if (!file) return;
setChatLoading(true, "Reading workspace backup...");
const isArchive = file.name.toLowerCase().endsWith(".tar.gz");
if (isArchive) {
const submit = async (path, allowDuplicates = false) => {
const body = new FormData();
body.append("archive", file);
if (allowDuplicates) body.append("allowDuplicates", "true");
const response = await fetch(path, {method: "POST", body});
return response.json();
};
try {
const preview = await submit("/imports/workspace/archive/preview");
setChatLoading(false);
if (preview.error || preview.ok === false) return setNotice((preview.errors || []).slice(0, 4).join("; ") || preview.error || "Invalid archive.", "error");
const confirmed = await confirmPopup({eyebrow: "Workspace import", title: "Review import",
description: formatImportPreview(preview.preview || {}, preview.warnings || []), confirmLabel: "Import"});
if (!confirmed) return;
setChatLoading(true, "Importing workspace and images...");
const result = await submit("/imports/workspace/archive", Boolean(preview.preview?.duplicates?.length));
if (result.error) return setNotice(result.error, "error");
await Promise.all([loadPersonas(), loadChatSessions()]);
setNotice(`Workspace imported: ${result.imported.chats} chats with their images.`, "success");
} catch { setNotice("Workspace archive could not be imported.", "error"); }
finally { setChatLoading(false); }
return;
}
let workspace;
try {
workspace = JSON.parse(await file.text());
@@ -2020,7 +2028,9 @@ async function checkSession() {
// Starters are offered on workspace home.
const starter = new URLSearchParams(location.search).get("prompt");
if (starter) { msgInput.value = starter; resizeComposerInput(); updateSendState(); }
msgInput.focus(); return true;
msgInput.focus();
if (!starter && !activeChatId) maybeShowOnboarding();
return true;
}
async function sendMessage({ retryMessage = null } = {}) {
@@ -2219,7 +2229,7 @@ if (headerModelButton) headerModelButton.addEventListener("click", (event) => {
openModelSettings();
});
saveGenerationSettingsBtn.addEventListener("click", () => { void saveGenerationSettings(); });
roleplayNewPersonaBtn.addEventListener("click", () => openPersonaForm(null, "assistant"));
roleplayNewPersonaBtn.addEventListener("click", () => { closeRoleplayStarter(); openPersonaForm(null, "assistant"); });
clearUserPersonaBtn.addEventListener("click", () => {
closeChatDrawer();
void clearPersona("user");
@@ -2281,12 +2291,14 @@ if (onboardingContinue) {
onboardingContinue.addEventListener("click", () => {
onboardingStep = 2;
renderOnboardingStep();
onboardingChoices.querySelector("button")?.focus();
});
}
if (onboardingBack) {
onboardingBack.addEventListener("click", () => {
onboardingStep = 1;
renderOnboardingStep();
(onboardingChoices.querySelector(".active") || onboardingChoices.querySelector("button"))?.focus();
});
}
if (onboardingSkip) {
@@ -2319,6 +2331,15 @@ document.addEventListener("keydown", (event) => {
}
}
if (event.key === "Escape") {
const activeModal = topModal();
if (activeModal) {
event.preventDefault();
if (activeModal === popupModal) closePopup(false);
else if (activeModal === personaModal) closePersonaForm();
else if (activeModal === roleplayStarterModal) closeRoleplayStarter();
else if (activeModal === onboardingModal) closeOnboarding();
return;
}
const openMenu = document.querySelector(".msg-action-menu[open]");
if (openMenu) {
openMenu.open = false;
+14 -5
View File
@@ -9,6 +9,7 @@ import {
} from "./themeController.js?v=20260909-account-controls";
import { clearStatus, setStatus } from "./statusNotice.js?v=20260909-account-controls";
import { populatePersonaForm, readPersonaForm } from "./personaForm.js?v=20260909-account-controls";
import { openModal, closeModal, topModal } from "./modalFocus.js";
const $ = (id) => document.getElementById(id);
const settingsNotice = $("settingsNotice");
@@ -96,7 +97,7 @@ function setPersonaNotice(message = "", state = "") {
}
function closeSettingsPopup(value = null) {
settingsPopupModal.classList.add("hidden");
closeModal(settingsPopupModal);
if (settingsPopupResolver) {
const resolve = settingsPopupResolver;
settingsPopupResolver = null;
@@ -120,7 +121,7 @@ function promptSettingsPopup({
settingsPopupInput.value = value;
settingsPopupInput.placeholder = placeholder;
settingsPopupConfirm.textContent = confirmLabel;
settingsPopupModal.classList.remove("hidden");
openModal(settingsPopupModal, settingsPopupInput);
settingsPopupInput.focus();
settingsPopupInput.select();
return new Promise((resolve) => {
@@ -232,12 +233,11 @@ function openPersonaModal(persona = null, personaType = "assistant") {
populatePersonaForm(settingsPersonaFields, persona || {});
syncPersonaExamplesField();
setPersonaNotice("");
settingsPersonaModal.classList.remove("hidden");
settingsPersonaName.focus();
openModal(settingsPersonaModal, settingsPersonaName);
}
function closePersonaModal() {
settingsPersonaModal.classList.add("hidden");
closeModal(settingsPersonaModal);
editingPersonaId = null;
setPersonaNotice("");
}
@@ -571,9 +571,18 @@ settingsPopupInput.addEventListener("keydown", (event) => {
}
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
closeSettingsPopup(null);
}
});
document.addEventListener("keydown", event => {
if (event.key !== "Escape") return;
const modal = topModal();
if (!modal) return;
event.preventDefault();
if (modal === settingsPopupModal) closeSettingsPopup(null);
else if (modal === settingsPersonaModal) closePersonaModal();
});
window.addEventListener("load", async () => {
initializeAppearance({ title: "Elsewhere Settings", themeSelect: settingsThemeSelect, modeSelect: themeModeSelect });
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* @file Verbindet die Abmeldeknöpfe des gemeinsamen Seitenkopfs mit der Sitzungsverwaltung.
*/
import {post} from './api.js?v=20260909-account-controls';
import {post} from './api.js?v=20260909-account-controls';
// Every page gets this control from the same server-rendered header.
for (const button of document.querySelectorAll('[data-sign-out]')) {
+2 -2
View File
@@ -102,7 +102,7 @@
</main>
<div id="marketPreviewModal" class="modal-overlay hidden">
<div class="modal-card market-preview-card">
<div class="modal-card market-preview-card" role="dialog" aria-modal="true" aria-labelledby="marketPreviewTitle" tabindex="-1">
<div class="modal-header">
<div>
<p class="eyebrow">Persona preview</p>
@@ -162,7 +162,7 @@
</div>
<div id="marketPersonaModal" class="modal-overlay hidden">
<div class="modal-card persona-modal-card">
<div class="modal-card persona-modal-card" role="dialog" aria-modal="true" aria-labelledby="marketPersonaFormTitle" tabindex="-1">
<div class="modal-header">
<div class="persona-modal-heading">
<p class="eyebrow">AI Character editor</p>
+1 -1
View File
@@ -232,7 +232,7 @@
</main>
<div id="settingsPersonaModal" class="modal-overlay hidden">
<div class="modal-card persona-modal-card">
<div class="modal-card persona-modal-card" role="dialog" aria-modal="true" aria-labelledby="settingsPersonaFormTitle" tabindex="-1">
<div class="modal-header">
<div class="persona-modal-heading">
<p class="eyebrow">Persona editor</p>