From 9020167a92cb652adb935925111bef89e792b19a Mon Sep 17 00:00:00 2001 From: dwrz Date: Fri, 30 Oct 2026 23:06:09 +0000 Subject: [PATCH] Add conversations modal --- internal/service/static/css/main.css | 134 ++++++++++++++++++ internal/service/static/js/api.js | 21 +++ internal/service/static/js/main.js | 100 ++++++++++++- internal/service/static/js/ui.js | 125 ++++++++++++++++ internal/service/templates/static/body.gohtml | 1 + .../templates/static/footer/toolbar.gohtml | 9 ++ .../static/modal/conversations.gohtml | 40 ++++++ 7 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 internal/service/templates/static/modal/conversations.gohtml diff --git a/internal/service/static/css/main.css b/internal/service/static/css/main.css index b3639cb..02d4975 100644 --- a/internal/service/static/css/main.css +++ b/internal/service/static/css/main.css @@ -945,6 +945,140 @@ body { color: var(--color-red); } +/* ==================== */ +/* Conversations Modal */ +/* ==================== */ + +.conversations-overlay { + display: none; + position: fixed; + inset: 0; + z-index: 1000; +} + +.conversations-overlay.open { + display: block; +} + +/* The picker fills the screen. The search field stays at the top + and the list scrolls below it. */ +.conversations-panel { + display: flex; + flex-direction: column; + width: 100%; + height: 100dvh; + background: var(--color-surface); +} + +.conversations-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--s-1) var(--s1); + border-bottom: 1px solid var(--color-border-light); + flex-shrink: 0; +} + +.conversations-panel__title { + font-size: var(--s1); + font-weight: 600; + color: var(--color-text); + margin: 0; +} + +#conversations-close { + border: none; + color: var(--color-text); +} + +@media (hover: hover) { + #conversations-close:hover { + border-color: transparent; + color: var(--color-text); + } +} + +.conversations-panel__search { + padding: var(--s-1) var(--s1); + border-bottom: 1px solid var(--color-border-light); + flex-shrink: 0; +} + +.conversations-search { + display: block; + width: 100%; + height: var(--s2); + padding: 0 var(--s-1); + font-family: inherit; + font-size: var(--s-1); + line-height: var(--s2); + color: var(--color-text); + background: var(--color-bg); + border: 1px solid var(--color-border-light); + border-radius: var(--radius); +} + +.conversations-search:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.conversations-panel__list { + flex: 1; + overflow-y: auto; + padding: var(--s-1) var(--s1); +} + +.conversations__item { + display: flex; + flex-direction: column; + gap: var(--s-2); + width: 100%; + padding: var(--s-1); + font-family: inherit; + text-align: left; + background: transparent; + border: none; + border-radius: var(--radius); + cursor: pointer; +} + +@media (hover: hover) { + .conversations__item:hover { + background: var(--color-gray1); + } +} + +.conversations__item:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} + +.conversations__item--current { + background: var(--color-gray1); +} + +.conversations__item-title { + font-size: var(--s0); + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversations__item-updated { + font-size: var(--s-1); + font-family: var(--font-mono); + color: var(--color-text-muted); +} + +.conversations__empty { + padding: var(--s2); + font-size: var(--s0); + color: var(--color-text-muted); + text-align: center; +} + /* ==================== */ .modal-overlay { position: fixed; diff --git a/internal/service/static/js/api.js b/internal/service/static/js/api.js index 428b6aa..d1603c9 100644 --- a/internal/service/static/js/api.js +++ b/internal/service/static/js/api.js @@ -55,6 +55,27 @@ export class Api { // --- Conversations --- + /** + * listConversations returns every conversation of the user, most + * recently updated first. Each row holds id, title, created_at, + * and updated_at. It holds no messages. + * @returns {Promise>} + */ + async listConversations() { + return this._json('/conversations'); + } + + /** + * getConversation returns one conversation row. Use it to verify + * that a conversation exists before the local selection moves to + * it. It returns no messages. + * @param {string} id + * @returns {Promise} + */ + async getConversation(id) { + return this._json(`/conversations/${encodeURIComponent(id)}`); + } + /** * createConversation saves a new conversation with its initial * system message. An empty systemMessage makes the server use its diff --git a/internal/service/static/js/main.js b/internal/service/static/js/main.js index 437069c..164a944 100644 --- a/internal/service/static/js/main.js +++ b/internal/service/static/js/main.js @@ -19,7 +19,7 @@ import { Api, ApiError } from './api.js'; import { Storage } from './storage.js'; import { STT, VOICE_MAP } from './stt.js'; import { TTSPlayer } from './tts.js'; -import { extractText, UI } from './ui.js'; +import { conversationLabel, extractText, UI } from './ui.js'; import { WakeLock } from './wakelock.js'; // The tool result saved when the user denies a tool call. A denied @@ -69,6 +69,9 @@ class Odidere { onSettingsOpen: () => this.ui.setSystemMessageDraft(this.storage.getSystemMessage()), onSaveSystemMessage: (text) => this.storage.setSystemMessage(text), + onConversationsOpen: () => this.loadConversations(), + onConversationSearch: (query) => this.filterConversations(query), + onSelectConversation: (id) => this.switchConversation(id), onPushToTalkStart: () => this.handlePushToTalkStart(), onPushToTalkEnd: () => this.handlePushToTalkEnd(), onTranscribeStart: () => this.handleTranscribeStart(), @@ -106,6 +109,8 @@ class Odidere { // --- In-memory state --- this.conversationId = null; + // The rows the conversation picker shows, fetched on open. + this.conversations = []; this.messages = new Map(); this.drafts = new Map(); this.busy = false; @@ -716,6 +721,99 @@ class Odidere { this.updateSendEnabled(); } + // ==================== + // CONVERSATIONS + // ==================== + + /** + * loadConversations fetches the list for the picker and renders + * it. The server sends the rows most recently updated first, so + * the picker shows them in that order without sorting. + */ + async loadConversations() { + try { + this.conversations = await this.api.listConversations(); + } catch (error) { + this.ui.renderError(`Failed to list conversations: ${error.message}`); + this.ui.closeConversations(); + return; + } + this.ui.renderConversationList(this.conversations, { + currentId: this.conversationId, + }); + } + + /** + * filterConversations renders the conversations whose label + * matches the search text. The label is the title, or the + * creation date when the conversation has none. + * @param {string} query + */ + filterConversations(query) { + const needle = query.trim().toLowerCase(); + const rows = needle + ? this.conversations.filter((conversation) => + conversationLabel(conversation).toLowerCase().includes(needle), + ) + : this.conversations; + this.ui.renderConversationList(rows, { + currentId: this.conversationId, + }); + } + + /** + * switchConversation selects another conversation. The current + * one stays on the server; only the local view changes. + * + * The target is read first, so a conversation that another client + * deleted does not replace the one in view. + * + * A run in progress is detached, not stopped: the server keeps an + * accepted inference running after the client disconnects, and a + * later switch back re-attaches to it and replays what it saved. + * + * @param {string} id + */ + async switchConversation(id) { + this.ui.closeConversations(); + if (id === this.conversationId) return; + + try { + await this.api.getConversation(id); + } catch (error) { + this.ui.renderError(`Failed to open conversation: ${error.message}`); + return; + } + + this.tts.stop(); + // End delivery of the current streams. An accepted inference + // keeps running on the server; see the comment above. + this.syncController?.abort(); + if (this.busy) { + this.runController?.abort(); + if (this.currentRun) { + try { + await this.currentRun; + } catch { } + } + } + + // Drop the local state of the old selection. resync reads and + // renders the whole history of the new selection and caches it. + this.messages.clear(); + this.clearDrafts(); + this.totalTokens = 0; + this.lastVoice = ''; + this.storage.clearConversation(); + this.ui.setAutoScroll(false); + this.ui.clearChat(); + this.ui.setPendingApprovals([]); + this.ui.updateContext(0, null); + this.conversationId = id; + await this.resync(); + this.updateSendEnabled(); + } + // ==================== // SYNC // ==================== diff --git a/internal/service/static/js/ui.js b/internal/service/static/js/ui.js index 77f37f4..57b19fc 100644 --- a/internal/service/static/js/ui.js +++ b/internal/service/static/js/ui.js @@ -39,6 +39,27 @@ export function extractText(content) { .join('\n'); } +// Conversations created from this UI have no title. Their label +// falls back to the creation date, so the picker stays readable. +// Both dates render in the browser locale. +const CONVERSATION_DATE = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', +}); + +/** + * conversationLabel returns the text that names a conversation in + * the picker: its title, or its creation date when it has none. + * @param {{title: string | null, created_at: string}} conversation + * @returns {string} + */ +export function conversationLabel(conversation) { + return ( + conversation.title ?? + CONVERSATION_DATE.format(new Date(conversation.created_at)) + ); +} + export class UI { /** * @param {Object} options @@ -88,6 +109,18 @@ export class UI { this.$systemMessageInput = document.getElementById('system-message-input'); this.$saveSystemMessage = document.getElementById('save-system-message'); + // Conversations modal. + this.$conversations = document.getElementById('conversations'); + this.$conversationsOverlay = document.getElementById( + 'conversations-overlay', + ); + this.$conversationsClose = document.getElementById('conversations-close'); + this.$conversationsSearch = document.getElementById('conversation-search'); + this.$conversationsList = document.getElementById('conversation-list'); + this.$tplConversationItem = document.getElementById( + 'tpl-conversation-item', + ); + // Compose settings modal. this.$composeSettings = document.getElementById('compose-settings'); this.$composeSettingsOverlay = document.getElementById( @@ -824,6 +857,65 @@ export class UI { this.$composeSettingsClose.focus(); } + // ==================== + // CONVERSATIONS MODAL + // ==================== + + /** + * openConversations shows the conversation picker and asks + * main.js for a fresh list. The search field starts empty and + * holds the caret, so typing filters the list at once. + */ + openConversations() { + this.$conversationsSearch.value = ''; + this.$conversationsOverlay.classList.add('open'); + this.$conversationsSearch.focus(); + this.handlers.onConversationsOpen(); + } + + /** closeConversations hides the picker. */ + closeConversations() { + this.$conversationsOverlay.classList.remove('open'); + this.$conversations.focus(); + } + + /** + * renderConversationList fills the picker with the rows given, + * in the order given. main.js filters the rows; the row that + * matches currentId is marked as the conversation in view. The + * search field decides which empty message shows. + * @param {Array<{id: string, title: string | null, created_at: string, updated_at: string}>} rows + * @param {{currentId?: string | null}} [options] + */ + renderConversationList(rows, { currentId = null } = {}) { + this.$conversationsList.replaceChildren(); + if (rows.length === 0) { + const $empty = this.document.createElement('div'); + $empty.className = 'conversations__empty'; + $empty.textContent = this.$conversationsSearch.value + ? 'No conversations match the search.' + : 'No conversations yet.'; + this.$conversationsList.appendChild($empty); + return; + } + for (const conversation of rows) { + const $item = + this.$tplConversationItem.content.cloneNode(true).firstElementChild; + $item.dataset.conversationId = conversation.id; + $item.querySelector('.conversations__item-title').textContent = + conversationLabel(conversation); + $item.querySelector('.conversations__item-updated').textContent = + `Updated ${CONVERSATION_DATE.format( + new Date(conversation.updated_at), + )}`; + if (conversation.id === currentId) { + $item.classList.add('conversations__item--current'); + $item.setAttribute('aria-current', 'true'); + } + this.$conversationsList.appendChild($item); + } + } + // ==================== // MUTE // ==================== @@ -1025,6 +1117,35 @@ export class UI { this.handlers.onVoiceChange(this.$voice.value), ); + // Conversations modal. The toolbar button opens it through the + // data-action route; these listeners close it and report the + // search text and the selected row. + this.$conversationsClose.addEventListener('click', () => + this.closeConversations(), + ); + this.$conversationsOverlay.addEventListener('click', (event) => { + if (event.target === this.$conversationsOverlay) { + this.closeConversations(); + } + }); + this.document.addEventListener('keydown', (event) => { + if ( + event.key === 'Escape' && + this.$conversationsOverlay.classList.contains('open') + ) { + event.preventDefault(); + this.closeConversations(); + } + }); + this.$conversationsSearch.addEventListener('input', () => + this.handlers.onConversationSearch(this.$conversationsSearch.value), + ); + this.$conversationsList.addEventListener('click', (event) => { + const $item = event.target.closest('[data-conversation-id]'); + if (!$item) return; + this.handlers.onSelectConversation($item.dataset.conversationId); + }); + // Compose settings modal. this.$composeSettings.addEventListener('click', () => this.handlers.onComposeSettings(), @@ -1152,6 +1273,10 @@ export class UI { this._clearConfirm(); this.handlers.onLocationToggle(); break; + case 'conversations': + this._clearConfirm(); + this.openConversations(); + break; case 'settings': this._clearConfirm(); this._openSettings(); diff --git a/internal/service/templates/static/body.gohtml b/internal/service/templates/static/body.gohtml index f5bf196..8736850 100644 --- a/internal/service/templates/static/body.gohtml +++ b/internal/service/templates/static/body.gohtml @@ -3,6 +3,7 @@ {{ template "main" . }} {{ template "templates" . }} {{ template "modal/settings" . }} + {{ template "modal/conversations" . }} {{ template "modal/edit" . }} {{ template "modal/compose-settings" . }} diff --git a/internal/service/templates/static/footer/toolbar.gohtml b/internal/service/templates/static/footer/toolbar.gohtml index f738e22..7f67515 100644 --- a/internal/service/templates/static/footer/toolbar.gohtml +++ b/internal/service/templates/static/footer/toolbar.gohtml @@ -9,6 +9,15 @@ > + + + + + +
+ + + + +{{ end }}