make some overall QoL improvements
CI / check (22) (push) Failing after 15m53s
CI / check (24) (push) Failing after 15m50s

This commit is contained in:
Frederik Spirgi
2026-10-12 13:07:59 +02:00
parent f67d918d09
commit 7723125c93
11 changed files with 159 additions and 25 deletions
+17
View File
@@ -0,0 +1,17 @@
export function titleFromFirstMessage(message) {
const firstLine = String(message || "").split(/\r?\n/).find(line => line.trim()) || "";
let subject = firstLine.replace(/^\s*(?:[-*#]+\s*)?/, "").replace(/\s+/g, " ").trim();
subject = subject.replace(/^(?:can|could|would) you (?:please )?(?:help me )?/i, "")
.replace(/^(?:please )?(?:help me (?:with |understand )?|tell me about |explain |describe |summarize |compare |write about |create |make )/i, "")
.replace(/^what (?:is|are|was|were) (?:the |a |an )?/i, "")
.replace(/^how (?:do|does|can) (?:i|we) /i, "")
.replace(/^[\s:,-]+|[\s.!?,;:]+$/g, "");
const sentence = subject.split(/(?<=[.!?])\s+/)[0].replace(/[.!?]$/, "");
if (sentence) subject = sentence;
if (subject.length > 68) {
const words = subject.slice(0, 69).split(" ");
subject = words.length > 1 ? words.slice(0, -1).join(" ") : subject.slice(0, 68);
}
subject = subject.replace(/[\s,;:.-]+$/g, "");
return subject ? subject[0].toLocaleUpperCase() + subject.slice(1) : "New chat";
}
+8 -1
View File
@@ -2,6 +2,7 @@
import * as defaultApi from "./api.js";
import {updateReasoning, finishReasoning} from "./reasoning.js";
import {createStreamRenderer} from "./streamRenderer.js";
import {titleFromFirstMessage} from "./chatTitle.js";
export function createComposerController({
state, elements, attachmentComposer, webSearchButton, reasoningToggle, webSearchToggle,
@@ -98,7 +99,7 @@ export function createComposerController({
}
const currentChat = getChatById(chatId);
if (currentChat && !currentChat.assistant_persona_id && currentChat.title === "New chat") {
const title = message.length > 40 ? `${message.slice(0, 40)}...` : message;
const title = titleFromFirstMessage(message);
const renamed = await api.put(`/chats/${chatId}`, {title}, {signal});
signal.throwIfAborted();
if (renamed.error) throw new Error(renamed.error);
@@ -150,6 +151,12 @@ export function createComposerController({
}, {signal});
rendering.flush();
rendering.dispose();
const updatedChat = getChatById(chatId);
if (updatedChat && completedReply !== null) {
updatedChat.last_message_preview = completedReply.slice(0, 180);
updatedChat.updated_at = new Date().toISOString();
renderChatList();
}
// Keep a completed answer in both state and the DOM until history replaces it.
if (visible()) {
const reply = {role: "bot", content: completedReply, modelName: model,
+15
View File
@@ -23,10 +23,19 @@ export function createMessageRenderer({
}) {
const jumpToLatest = document.getElementById("jumpToLatest");
const searchInput = document.getElementById("conversationSearch");
const searchToggle = document.getElementById("conversationSearchToggle");
const searchBar = document.getElementById("conversationSearchBar");
const searchCount = document.getElementById("conversationSearchCount");
const previousMatch = document.getElementById("conversationSearchPrevious");
const nextMatch = document.getElementById("conversationSearchNext");
const replyAnnouncement = document.getElementById("replyAnnouncement");
searchToggle?.addEventListener("click", () => {
const expanded = searchToggle.getAttribute("aria-expanded") !== "true";
searchToggle.setAttribute("aria-expanded", String(expanded));
searchBar.classList.toggle("is-open", expanded);
if (expanded) searchInput.focus();
else searchToggle.focus();
});
let scrollFrame = null;
@@ -232,6 +241,12 @@ export function createMessageRenderer({
actions.setAttribute("aria-label", "Message actions");
const activeChat = getChatById(state.activeChatId);
header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant");
if (!isMessageUser && Array.isArray(message.sources) && message.sources.length) {
const webBadge = document.createElement("span");
webBadge.className = "msg-web-badge";
webBadge.textContent = "Used web results";
header.append(webBadge);
}
setMessageContent(body, message.content);
const copyBtn = document.createElement("button");
+1
View File
@@ -8,6 +8,7 @@ export function createReasoningToggle(button) {
let supported = false;
let busy = false;
const render = () => {
button.hidden = !supported;
button.disabled = !supported || busy;
button.setAttribute("aria-pressed", String(reasoningEnabled));
button.title = !supported ? "This model does not support reasoning"
+32 -7
View File
@@ -120,6 +120,7 @@ let workspaceMode = localStorage.getItem("krishd-workspace-mode") || "basic";
let onboardingStep = 1, onboardingIntent = "ask";
let chatLoadingDepth = 0;
let chatActivityDepth = 0;
let noticeTimer = null;
let availableModelsById = new Map();
const personalitySelect = document.getElementById("personalitySelect");
@@ -234,7 +235,15 @@ const branchExplorer = createBranchExplorer({state: conversation, setActiveChat,
function setNotice(message = "", state = "") {
const notice = document.getElementById("workspaceNotice");
if (notice) { notice.textContent = state === "error" ? message : ""; notice.classList.toggle("hidden", state !== "error"); }
clearTimeout(noticeTimer);
if (notice) {
const visible = state === "error" || state === "success";
notice.textContent = visible ? message : "";
notice.classList.toggle("hidden", !visible);
notice.classList.toggle("error", state === "error");
notice.classList.toggle("success", state === "success");
if (state === "success") noticeTimer = setTimeout(() => setNotice(), 3500);
}
return { message, state };
}
@@ -603,6 +612,18 @@ function openModelSettings() {
requestAnimationFrame(() => modelSelect?.focus());
}
function formatChatActivity(value) {
if (!value) return "";
const date = new Date(value.includes("T") ? value : `${value.replace(" ", "T")}Z`);
if (Number.isNaN(date.getTime())) return "";
const today = new Date();
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
if (sameDay(date, today)) return new Intl.DateTimeFormat(undefined, {hour: "numeric", minute: "2-digit"}).format(date);
const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1);
if (sameDay(date, yesterday)) return "Yesterday";
return new Intl.DateTimeFormat(undefined, {month: "short", day: "numeric", ...(date.getFullYear() === today.getFullYear() ? {} : {year: "numeric"})}).format(date);
}
function renderChatList() {
const query = chatSearchInput.value.trim().toLowerCase();
const source = query ? (conversation.chatSearchResults ?? []) : conversation.chatSessions;
@@ -657,7 +678,8 @@ function renderChatList() {
const chatType = chat.assistant_persona_id
? `Roleplay${chat.user_persona_name ? ` · ${chat.user_persona_name}` : ""}`
: "Assistant chat";
meta.textContent = chat.source_chat_id ? `Branch · ${chatType}` : chatType;
const activity = formatChatActivity(chat.updated_at);
meta.textContent = [chat.source_chat_id ? `Branch · ${chatType}` : chatType, activity].filter(Boolean).join(" · ");
remove.type = "button";
remove.className = "chat-item-delete";
remove.textContent = "Delete";
@@ -668,10 +690,11 @@ function renderChatList() {
await deleteChat(chat.id);
});
content.append(title, meta);
if (query && chat.matched_content) {
const preview = query && chat.matched_content ? chat.matched_content : chat.last_message_preview;
if (preview) {
const excerpt = document.createElement("small");
const normalized = chat.matched_content.replace(/\s+/g, " ");
const matchAt = normalized.toLowerCase().indexOf(query);
const normalized = preview.replace(/\s+/g, " ").trim();
const matchAt = query ? normalized.toLowerCase().indexOf(query) : -1;
const start = Math.max(0, matchAt - 35);
excerpt.className = "chat-item-excerpt";
excerpt.textContent = `${start ? "…" : ""}${normalized.slice(start, start + 110)}${normalized.length > start + 110 ? "…" : ""}`;
@@ -778,7 +801,7 @@ async function updateChatOrganization(id, changes) {
const res = await put(`/chats/${id}/organization`, payload);
if (res.error) return setNotice(res.error, "error");
const index = conversation.chatSessions.findIndex((item) => item.id === id);
if (index >= 0) conversation.chatSessions[index] = res.chat;
if (index >= 0) conversation.chatSessions[index] = {...conversation.chatSessions[index], ...res.chat};
updateWorkspaceCopy();
renderChatList();
updateChatActionState();
@@ -799,7 +822,9 @@ async function clearChat(id) {
if (res.error) return setNotice(res.error, "error");
conversation.messageRetryState = new Map();
conversation.historyPage = null;
conversation.currentMessages = []; renderMessages(); await loadSummary(); setNotice("Chat cleared.", "success");
chat.last_message_preview = null;
chat.updated_at = new Date().toISOString();
conversation.currentMessages = []; renderMessages(); renderChatList(); await loadSummary(); setNotice("Chat cleared.", "success");
}
const personaStateFields = [
+7 -3
View File
@@ -5,15 +5,19 @@
/** @typedef {{message: string, model: string, chatId: number, webSearch?: boolean, reasoning?: boolean}} ChatRequest */
/** @typedef {{role: string, content: string, thinking?: string, sources?: WebSource[]}} ChatMessage */
export function createWebSearchToggle(button) {
export function createWebSearchToggle(button, storage = globalThis.localStorage) {
const key = "krishd-web-search-enabled";
let enabled = true;
try { enabled = storage?.getItem(key) !== "false"; } catch { /* Storage may be unavailable. */ }
const render = () => {
button.setAttribute("aria-pressed", String(enabled));
button.title = enabled ? "Automatic web search on - click to disable" : "Web search off - click to enable";
button.title = enabled ? "Web search allowed. The assistant searches when useful. Click to turn off."
: "Web search off. Click to allow the assistant to search when useful.";
button.setAttribute("aria-label", button.title);
};
button.addEventListener("click", () => {
enabled = !enabled;
try { storage?.setItem(key, String(enabled)); } catch { /* Keep this session's choice. */ }
render();
});
render();
@@ -28,7 +32,7 @@ export function appendSources(container, sources) {
section.setAttribute("aria-label", "Sources");
const heading = document.createElement("div");
heading.className = "msg-sources-heading";
heading.textContent = "Sources";
heading.textContent = "Used web results · Sources";
section.appendChild(heading);
sources.slice(0, 5).forEach((source, index) => {
let url;