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
+8 -7
View File
@@ -11,7 +11,7 @@
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
rel="stylesheet"> rel="stylesheet">
<script src="/js/theme-bootstrap.js?v=20260909-account-controls"></script> <script src="/js/theme-bootstrap.js?v=20260909-account-controls"></script>
<link rel="stylesheet" href="/css/style.css?v=20261008-conversation-search" /> <link rel="stylesheet" href="/css/style.css?v=20261012-chat-usability" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/pages.css?v=20260909-account-controls"> <link rel="stylesheet" href="/css/pages.css?v=20260909-account-controls">
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls"> <link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
@@ -46,7 +46,7 @@
<section id="main" class="chat-main chat-main-focus" aria-label="Active conversation"> <section id="main" class="chat-main chat-main-focus" aria-label="Active conversation">
<div class="chat-heading"> <div class="chat-heading">
<p id="workspaceNotice" class="status error hidden" role="status" aria-live="polite"></p> <p id="workspaceNotice" class="status hidden" role="status" aria-live="polite"></p>
<header class="chat-header chat-header-focus"> <header class="chat-header chat-header-focus">
<button id="chatSidebarToggle" class="icon-button sidebar-toggle" type="button" <button id="chatSidebarToggle" class="icon-button sidebar-toggle" type="button"
aria-controls="chatSidebar" aria-expanded="false" aria-controls="chatSidebar" aria-expanded="false"
@@ -239,7 +239,8 @@
<button id="loadOlderMessages" class="secondary-action" type="button" hidden>Load older messages</button> <button id="loadOlderMessages" class="secondary-action" type="button" hidden>Load older messages</button>
<button id="loadLatestMessages" class="secondary-action" type="button" hidden>Back to latest messages</button> <button id="loadLatestMessages" class="secondary-action" type="button" hidden>Back to latest messages</button>
</div> </div>
<div class="conversation-search" role="search" aria-label="Search this conversation"> <button id="conversationSearchToggle" class="conversation-search-toggle secondary-action" type="button" aria-controls="conversationSearchBar" aria-expanded="false">Find in this chat</button>
<div id="conversationSearchBar" class="conversation-search" role="search" aria-label="Search this conversation">
<label for="conversationSearch">Find in this chat</label> <label for="conversationSearch">Find in this chat</label>
<input id="conversationSearch" type="search" placeholder="Search messages" autocomplete="off" maxlength="200"> <input id="conversationSearch" type="search" placeholder="Search messages" autocomplete="off" maxlength="200">
<span id="conversationSearchCount" role="status" aria-live="polite">0 matches</span> <span id="conversationSearchCount" role="status" aria-live="polite">0 matches</span>
@@ -268,8 +269,8 @@
<path d="m3 17 5-5 4 4 4-6 5 7" /> <path d="m3 17 5-5 4 4 4-6 5 7" />
</svg><span class="composer-tool-label">Attach</span> </svg><span class="composer-tool-label">Attach</span>
</button> </button>
<button id="webSearch" class="icon-button" type="button" aria-label="Automatic web search on — click to disable" <button id="webSearch" class="icon-button" type="button" aria-label="Web search allowed. The assistant searches when useful. Click to turn off."
title="Automatic web search on — click to disable" aria-pressed="true"> title="Web search allowed. The assistant searches when useful. Click to turn off." aria-pressed="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.6" aria-hidden="true"> stroke-width="1.6" aria-hidden="true">
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
@@ -278,7 +279,7 @@
</svg><span class="composer-tool-label">Web search</span> </svg><span class="composer-tool-label">Web search</span>
</button> </button>
<button id="reasoning" class="icon-button" type="button" aria-label="Enable reasoning" <button id="reasoning" class="icon-button" type="button" aria-label="Enable reasoning"
title="Enable reasoning" aria-pressed="false" disabled> title="Enable reasoning" aria-pressed="false" disabled hidden>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true"> aria-hidden="true">
@@ -452,7 +453,7 @@
integrity="sha512-rCQgmUulW6f6QegOvTntKKb5IAoxTpGVCdWqYjkXEpzAns6XUFs8NKVqWe+KQpctp/EoRSFSuykVputqknLYMg==" integrity="sha512-rCQgmUulW6f6QegOvTntKKb5IAoxTpGVCdWqYjkXEpzAns6XUFs8NKVqWe+KQpctp/EoRSFSuykVputqknLYMg=="
crossorigin="anonymous" defer></script> crossorigin="anonymous" defer></script>
<script src="/vendor/dompurify.js" defer></script> <script src="/vendor/dompurify.js" defer></script>
<script type="module" src="/js/script.js?v=20261008-conversation-search"></script> <script type="module" src="/js/script.js?v=20261012-chat-usability"></script>
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script> <script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
</body> </body>
+16 -5
View File
@@ -424,7 +424,9 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
.chat-header-actions { display: flex; align-items: center; } .chat-header-actions { display: flex; align-items: center; }
.overflow-trigger { min-height: 38px; padding: 8px 12px; border-radius: 999px; font-size: .8rem; } .overflow-trigger { min-height: 38px; padding: 8px 12px; border-radius: 999px; font-size: .8rem; }
.messages-wrap { min-height: 0; position: relative; display: grid; grid-template-rows: auto auto minmax(0, 1fr); } .messages-wrap { min-height: 0; position: relative; display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); }
.messages-wrap > .messages-panel { grid-row: 4; }
.conversation-search-toggle { display: none; }
.conversation-search { padding: 8px max(16px, calc((100% - 940px) / 2)); display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); } .conversation-search { padding: 8px max(16px, calc((100% - 940px) / 2)); display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); }
.conversation-search label { flex: 0 0 auto; color: var(--text-soft); font-size: .76rem; font-weight: 700; } .conversation-search label { flex: 0 0 auto; color: var(--text-soft); font-size: .76rem; font-weight: 700; }
.conversation-search input { min-width: 0; width: min(280px, 100%); min-height: 34px; } .conversation-search input { min-width: 0; width: min(280px, 100%); min-height: 34px; }
@@ -451,6 +453,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
.msg.user { align-self: flex-end; padding: 10px 13px; color: var(--message-user-text); background: var(--message-user); border-radius: 17px 17px 5px 17px; } .msg.user { align-self: flex-end; padding: 10px 13px; color: var(--message-user-text); background: var(--message-user); border-radius: 17px 17px 5px 17px; }
.msg.bot, .msg.loading { width: fit-content; min-width: min(280px, 80%); align-self: flex-start; padding: 12px 14px; background: var(--message-bot); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); } .msg.bot, .msg.loading { width: fit-content; min-width: min(280px, 80%); align-self: flex-start; padding: 12px 14px; background: var(--message-bot); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); }
.msg-header { display: flex; align-items: center; justify-content: space-between; color: var(--text-muted); font-size: .72rem; } .msg-header { display: flex; align-items: center; justify-content: space-between; color: var(--text-muted); font-size: .72rem; }
.msg-web-badge { margin-left: 12px; color: var(--brand-strong); font-weight: 700; white-space: nowrap; }
.msg-content { min-width: 0; overflow-wrap: anywhere; } .msg-content { min-width: 0; overflow-wrap: anywhere; }
.msg-content > :last-child { margin-bottom: 0; } .msg-content > :last-child { margin-bottom: 0; }
.msg-content pre { max-width: 100%; padding: 14px; overflow: auto; background: var(--canvas); border-radius: var(--radius-sm); } .msg-content pre { max-width: 100%; padding: 14px; overflow: auto; background: var(--canvas); border-radius: var(--radius-sm); }
@@ -506,6 +509,8 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
.composer-hint { margin: 0; color: var(--text-muted); font-size: .72rem; } .composer-hint { margin: 0; color: var(--text-muted); font-size: .72rem; }
.input-container { padding: 7px; display: grid; grid-template-columns: 44px 44px 44px minmax(0, 1fr) 44px 58px; grid-template-areas: "attach search reasoning message send stop"; gap: 7px; align-items: end; background: var(--input-bg); border: 1px solid var(--border-strong); border-radius: 24px; box-shadow: var(--shadow-sm); } .input-container { padding: 7px; display: grid; grid-template-columns: 44px 44px 44px minmax(0, 1fr) 44px 58px; grid-template-areas: "attach search reasoning message send stop"; gap: 7px; align-items: end; background: var(--input-bg); border: 1px solid var(--border-strong); border-radius: 24px; box-shadow: var(--shadow-sm); }
.input-container:not(:has(#stopGeneration:not([hidden]))) { grid-template-columns: 44px 44px 44px minmax(0, 1fr) 44px; grid-template-areas: "attach search reasoning message send"; } .input-container:not(:has(#stopGeneration:not([hidden]))) { grid-template-columns: 44px 44px 44px minmax(0, 1fr) 44px; grid-template-areas: "attach search reasoning message send"; }
.input-container:not(:has(#reasoning:not([hidden]))) { grid-template-columns: 44px 44px minmax(0, 1fr) 44px 58px; grid-template-areas: "attach search message send stop"; }
.input-container:not(:has(#reasoning:not([hidden]))):not(:has(#stopGeneration:not([hidden]))) { grid-template-columns: 44px 44px minmax(0, 1fr) 44px; grid-template-areas: "attach search message send"; }
.input-container [hidden] { display: none !important; } .input-container [hidden] { display: none !important; }
.composer-tool-label { display: none; } .composer-tool-label { display: none; }
#attachImage { grid-area: attach; } #attachImage { grid-area: attach; }
@@ -695,14 +700,20 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
.messages-empty-actions { display: grid; } .messages-empty-actions { display: grid; }
.chat-drawer-action-list { grid-template-columns: 1fr; } .chat-drawer-action-list { grid-template-columns: 1fr; }
.chat-drawer-action-list > * { grid-column: auto; } .chat-drawer-action-list > * { grid-column: auto; }
.conversation-search-toggle { display: block; justify-self: start; min-height: 32px; margin: 4px 10px; padding: 4px 10px; font-size: .75rem; }
.conversation-search:not(.is-open) { display: none; }
.conversation-search { padding: 6px 10px; flex-wrap: wrap; gap: 5px; } .conversation-search { padding: 6px 10px; flex-wrap: wrap; gap: 5px; }
.conversation-search label { width: 100%; } .conversation-search label { width: 100%; }
.conversation-search input { flex: 1 1 110px; } .conversation-search input { flex: 1 1 110px; }
.conversation-search span { min-width: 55px; } .conversation-search span { min-width: 55px; }
.input-container { grid-template-columns: repeat(3, minmax(0, 1fr)) 44px 58px; grid-template-areas: "message message message send stop" "attach search reasoning . ."; gap: 4px; } .input-container, .input-container:not(:has(#stopGeneration:not([hidden]))) { grid-template-columns: 40px 40px 40px minmax(0, 1fr) 44px; grid-template-areas: "attach search reasoning message send"; gap: 4px; }
.input-container:not(:has(#stopGeneration:not([hidden]))) { grid-template-columns: repeat(3, minmax(0, 1fr)) 44px; grid-template-areas: "message message message send" "attach search reasoning ."; } .input-container:not(:has(#reasoning:not([hidden]))), .input-container:not(:has(#reasoning:not([hidden]))):not(:has(#stopGeneration:not([hidden]))) { grid-template-columns: 40px 40px minmax(0, 1fr) 44px; grid-template-areas: "attach search message send"; }
#attachImage, #webSearch, #reasoning { width: 100%; height: 49px; min-height: 49px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; border-radius: 12px; } .input-container:has(#stopGeneration:not([hidden])) #send { display: none; }
.composer-tool-label { display: inline; font-size: .63rem; line-height: 1; } .input-container:has(#stopGeneration:not([hidden])) { grid-template-areas: "attach search reasoning message stop"; }
.input-container:not(:has(#reasoning:not([hidden]))):has(#stopGeneration:not([hidden])) { grid-template-areas: "attach search message stop"; }
#attachImage, #webSearch, #reasoning { width: 40px; height: 44px; min-height: 44px; }
#stopGeneration { width: 44px; font-size: .7rem; }
.composer-tool-label { display: none; }
} }
@media (hover: none) { .msg-actions { opacity: 1; } } @media (hover: none) { .msg-actions { opacity: 1; } }
+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 * as defaultApi from "./api.js";
import {updateReasoning, finishReasoning} from "./reasoning.js"; import {updateReasoning, finishReasoning} from "./reasoning.js";
import {createStreamRenderer} from "./streamRenderer.js"; import {createStreamRenderer} from "./streamRenderer.js";
import {titleFromFirstMessage} from "./chatTitle.js";
export function createComposerController({ export function createComposerController({
state, elements, attachmentComposer, webSearchButton, reasoningToggle, webSearchToggle, state, elements, attachmentComposer, webSearchButton, reasoningToggle, webSearchToggle,
@@ -98,7 +99,7 @@ export function createComposerController({
} }
const currentChat = getChatById(chatId); const currentChat = getChatById(chatId);
if (currentChat && !currentChat.assistant_persona_id && currentChat.title === "New chat") { 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}); const renamed = await api.put(`/chats/${chatId}`, {title}, {signal});
signal.throwIfAborted(); signal.throwIfAborted();
if (renamed.error) throw new Error(renamed.error); if (renamed.error) throw new Error(renamed.error);
@@ -150,6 +151,12 @@ export function createComposerController({
}, {signal}); }, {signal});
rendering.flush(); rendering.flush();
rendering.dispose(); 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. // Keep a completed answer in both state and the DOM until history replaces it.
if (visible()) { if (visible()) {
const reply = {role: "bot", content: completedReply, modelName: model, const reply = {role: "bot", content: completedReply, modelName: model,
+15
View File
@@ -23,10 +23,19 @@ export function createMessageRenderer({
}) { }) {
const jumpToLatest = document.getElementById("jumpToLatest"); const jumpToLatest = document.getElementById("jumpToLatest");
const searchInput = document.getElementById("conversationSearch"); const searchInput = document.getElementById("conversationSearch");
const searchToggle = document.getElementById("conversationSearchToggle");
const searchBar = document.getElementById("conversationSearchBar");
const searchCount = document.getElementById("conversationSearchCount"); const searchCount = document.getElementById("conversationSearchCount");
const previousMatch = document.getElementById("conversationSearchPrevious"); const previousMatch = document.getElementById("conversationSearchPrevious");
const nextMatch = document.getElementById("conversationSearchNext"); const nextMatch = document.getElementById("conversationSearchNext");
const replyAnnouncement = document.getElementById("replyAnnouncement"); 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; let scrollFrame = null;
@@ -232,6 +241,12 @@ export function createMessageRenderer({
actions.setAttribute("aria-label", "Message actions"); actions.setAttribute("aria-label", "Message actions");
const activeChat = getChatById(state.activeChatId); const activeChat = getChatById(state.activeChatId);
header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant"); 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); setMessageContent(body, message.content);
const copyBtn = document.createElement("button"); const copyBtn = document.createElement("button");
+1
View File
@@ -8,6 +8,7 @@ export function createReasoningToggle(button) {
let supported = false; let supported = false;
let busy = false; let busy = false;
const render = () => { const render = () => {
button.hidden = !supported;
button.disabled = !supported || busy; button.disabled = !supported || busy;
button.setAttribute("aria-pressed", String(reasoningEnabled)); button.setAttribute("aria-pressed", String(reasoningEnabled));
button.title = !supported ? "This model does not support reasoning" 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 onboardingStep = 1, onboardingIntent = "ask";
let chatLoadingDepth = 0; let chatLoadingDepth = 0;
let chatActivityDepth = 0; let chatActivityDepth = 0;
let noticeTimer = null;
let availableModelsById = new Map(); let availableModelsById = new Map();
const personalitySelect = document.getElementById("personalitySelect"); const personalitySelect = document.getElementById("personalitySelect");
@@ -234,7 +235,15 @@ const branchExplorer = createBranchExplorer({state: conversation, setActiveChat,
function setNotice(message = "", state = "") { function setNotice(message = "", state = "") {
const notice = document.getElementById("workspaceNotice"); 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 }; return { message, state };
} }
@@ -603,6 +612,18 @@ function openModelSettings() {
requestAnimationFrame(() => modelSelect?.focus()); 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() { function renderChatList() {
const query = chatSearchInput.value.trim().toLowerCase(); const query = chatSearchInput.value.trim().toLowerCase();
const source = query ? (conversation.chatSearchResults ?? []) : conversation.chatSessions; const source = query ? (conversation.chatSearchResults ?? []) : conversation.chatSessions;
@@ -657,7 +678,8 @@ function renderChatList() {
const chatType = chat.assistant_persona_id const chatType = chat.assistant_persona_id
? `Roleplay${chat.user_persona_name ? ` · ${chat.user_persona_name}` : ""}` ? `Roleplay${chat.user_persona_name ? ` · ${chat.user_persona_name}` : ""}`
: "Assistant chat"; : "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.type = "button";
remove.className = "chat-item-delete"; remove.className = "chat-item-delete";
remove.textContent = "Delete"; remove.textContent = "Delete";
@@ -668,10 +690,11 @@ function renderChatList() {
await deleteChat(chat.id); await deleteChat(chat.id);
}); });
content.append(title, meta); 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 excerpt = document.createElement("small");
const normalized = chat.matched_content.replace(/\s+/g, " "); const normalized = preview.replace(/\s+/g, " ").trim();
const matchAt = normalized.toLowerCase().indexOf(query); const matchAt = query ? normalized.toLowerCase().indexOf(query) : -1;
const start = Math.max(0, matchAt - 35); const start = Math.max(0, matchAt - 35);
excerpt.className = "chat-item-excerpt"; excerpt.className = "chat-item-excerpt";
excerpt.textContent = `${start ? "…" : ""}${normalized.slice(start, start + 110)}${normalized.length > start + 110 ? "…" : ""}`; 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); const res = await put(`/chats/${id}/organization`, payload);
if (res.error) return setNotice(res.error, "error"); if (res.error) return setNotice(res.error, "error");
const index = conversation.chatSessions.findIndex((item) => item.id === id); 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(); updateWorkspaceCopy();
renderChatList(); renderChatList();
updateChatActionState(); updateChatActionState();
@@ -799,7 +822,9 @@ async function clearChat(id) {
if (res.error) return setNotice(res.error, "error"); if (res.error) return setNotice(res.error, "error");
conversation.messageRetryState = new Map(); conversation.messageRetryState = new Map();
conversation.historyPage = null; 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 = [ const personaStateFields = [
+7 -3
View File
@@ -5,15 +5,19 @@
/** @typedef {{message: string, model: string, chatId: number, webSearch?: boolean, reasoning?: boolean}} ChatRequest */ /** @typedef {{message: string, model: string, chatId: number, webSearch?: boolean, reasoning?: boolean}} ChatRequest */
/** @typedef {{role: string, content: string, thinking?: string, sources?: WebSource[]}} ChatMessage */ /** @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; let enabled = true;
try { enabled = storage?.getItem(key) !== "false"; } catch { /* Storage may be unavailable. */ }
const render = () => { const render = () => {
button.setAttribute("aria-pressed", String(enabled)); 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.setAttribute("aria-label", button.title);
}; };
button.addEventListener("click", () => { button.addEventListener("click", () => {
enabled = !enabled; enabled = !enabled;
try { storage?.setItem(key, String(enabled)); } catch { /* Keep this session's choice. */ }
render(); render();
}); });
render(); render();
@@ -28,7 +32,7 @@ export function appendSources(container, sources) {
section.setAttribute("aria-label", "Sources"); section.setAttribute("aria-label", "Sources");
const heading = document.createElement("div"); const heading = document.createElement("div");
heading.className = "msg-sources-heading"; heading.className = "msg-sources-heading";
heading.textContent = "Sources"; heading.textContent = "Used web results · Sources";
section.appendChild(heading); section.appendChild(heading);
sources.slice(0, 5).forEach((source, index) => { sources.slice(0, 5).forEach((source, index) => {
let url; let url;
+7 -1
View File
@@ -31,7 +31,10 @@ export function createChatStatements(db) {
cs.response_length, cs.response_length,
cs.system_instruction, cs.system_instruction,
cs.created_at, cs.created_at,
cs.updated_at cs.updated_at,
(SELECT substr(m.content, 1, 180) FROM chat_messages m
WHERE m.chat_id = cs.id AND m.content <> ''
ORDER BY m.id DESC LIMIT 1) AS last_message_preview
FROM chat_sessions cs FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id LEFT JOIN personas up ON up.id = cs.user_persona_id
@@ -350,6 +353,9 @@ export function createChatStatements(db) {
cs.system_instruction, cs.system_instruction,
cs.created_at, cs.created_at,
cs.updated_at, cs.updated_at,
(SELECT substr(m.content, 1, 180) FROM chat_messages m
WHERE m.chat_id = cs.id AND m.content <> ''
ORDER BY m.id DESC LIMIT 1) AS last_message_preview,
cm.id AS matched_message_id, cm.id AS matched_message_id,
cm.content AS matched_content cm.content AS matched_content
FROM chat_sessions cs FROM chat_sessions cs
+47
View File
@@ -0,0 +1,47 @@
import test from "node:test";
import assert from "node:assert/strict";
import Database from "better-sqlite3";
import {initializeSchema, runMigrations} from "../src/db/schema.js";
import {createChatStatements} from "../src/db/chatRepository.js";
import {titleFromFirstMessage} from "../public/js/chatTitle.js";
import {createWebSearchToggle} from "../public/js/webSearch.js";
test("first messages produce short subject titles at word boundaries", () => {
assert.equal(titleFromFirstMessage("Can you help me plan a weekend trip to Lucerne? I prefer trains."), "Plan a weekend trip to Lucerne");
assert.equal(titleFromFirstMessage("What are the best hiking routes near Zurich?"), "Best hiking routes near Zurich");
const title = titleFromFirstMessage("Please explain the differences between local browser storage and server-side sessions for returning visitors");
assert.ok(title.length <= 68);
assert.ok(!title.endsWith("-"));
assert.equal(titleFromFirstMessage(" "), "New chat");
});
test("web search preference survives a fresh toggle", () => {
const values = new Map();
const storage = {getItem: key => values.get(key), setItem: (key, value) => values.set(key, value)};
const button = () => ({attributes: {}, setAttribute(key, value) { this.attributes[key] = value; }, addEventListener(_event, listener) { this.click = listener; }});
const first = button();
assert.equal(createWebSearchToggle(first, storage).enabled, true);
first.click();
const second = button();
assert.equal(createWebSearchToggle(second, storage).enabled, false);
assert.equal(second.attributes["aria-pressed"], "false");
second.click();
assert.equal(createWebSearchToggle(button(), storage).enabled, true);
});
test("chat list query returns a bounded latest message preview and activity time", () => {
const db = new Database(":memory:");
try {
initializeSchema(db);
runMigrations(db);
db.prepare("INSERT INTO chat_sessions (username, title) VALUES (?, ?)").run("owner", "Trip plans");
const chatId = db.prepare("SELECT id FROM chat_sessions").get().id;
db.prepare("INSERT INTO chat_messages (chat_id, role, content) VALUES (?, ?, ?)").run(chatId, "user", "First question");
db.prepare("INSERT INTO chat_messages (chat_id, role, content) VALUES (?, ?, ?)").run(chatId, "bot", "x".repeat(250));
const statements = createChatStatements(db);
const chat = statements.listChatSessions.get("owner");
assert.equal(chat.last_message_preview, "x".repeat(180));
assert.ok(chat.updated_at);
assert.equal(statements.searchChats.get("%Trip%", "owner", "%Trip%").last_message_preview, "x".repeat(180));
} finally { db.close(); }
});
+1 -1
View File
@@ -442,7 +442,7 @@ test("composer search defaults on and source renderer supports old messages and
const toggle = createWebSearchToggle(button); const toggle = createWebSearchToggle(button);
assert.equal(toggle.enabled, true); assert.equal(toggle.enabled, true);
assert.equal(button.attributes["aria-pressed"], "true"); assert.equal(button.attributes["aria-pressed"], "true");
assert.match(button.title, /Automatic web search on/); assert.match(button.title, /Web search allowed/);
button.listeners.click(); button.listeners.click();
assert.equal(toggle.enabled, false); assert.equal(toggle.enabled, false);
assert.equal(button.attributes["aria-pressed"], "false"); assert.equal(button.attributes["aria-pressed"], "false");