From 456d9ee6f3258e91fbb122653d2a384cb0467c7d Mon Sep 17 00:00:00 2001 From: dwrz Date: Sat, 31 Oct 2026 17:20:37 +0000 Subject: [PATCH] Add new conversation button --- internal/service/static/css/main.css | 39 ++++++++++- internal/service/static/icons.svg | 4 ++ internal/service/static/js/main.js | 65 +++++++++++++------ internal/service/static/js/ui.js | 4 ++ .../static/modal/conversations.gohtml | 8 +++ 5 files changed, 98 insertions(+), 22 deletions(-) diff --git a/internal/service/static/css/main.css b/internal/service/static/css/main.css index 381ad4c..dfa70e2 100644 --- a/internal/service/static/css/main.css +++ b/internal/service/static/css/main.css @@ -999,6 +999,9 @@ body { } .conversations-panel__search { + display: flex; + align-items: center; + gap: var(--s-1); padding: var(--s-1) var(--s1); border-bottom: 1px solid var(--color-border-light); flex-shrink: 0; @@ -1006,7 +1009,7 @@ body { .conversations-search { display: block; - width: 100%; + flex: 1; height: var(--s2); padding: 0 var(--s-1); font-family: inherit; @@ -1023,6 +1026,40 @@ body { outline-offset: 2px; } +/* The new-conversation button pairs with the search input: the + same height, the same border, one icon. */ +.conversations-new { + display: flex; + align-items: center; + justify-content: center; + width: var(--s2); + height: var(--s2); + padding: 0; + font-family: inherit; + color: var(--color-text-muted); + background: transparent; + border: 1px solid var(--color-border-light); + border-radius: var(--radius); + cursor: pointer; +} + +.conversations-new svg { + width: var(--icon-size); + height: var(--icon-size); +} + +.conversations-new:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +@media (hover: hover) { + .conversations-new:hover { + color: var(--color-text); + border-color: var(--color-text-muted); + } +} + .conversations-panel__list { flex: 1; overflow-y: auto; diff --git a/internal/service/static/icons.svg b/internal/service/static/icons.svg index c06351b..5929cb7 100644 --- a/internal/service/static/icons.svg +++ b/internal/service/static/icons.svg @@ -146,4 +146,8 @@ + + + + diff --git a/internal/service/static/js/main.js b/internal/service/static/js/main.js index 7068c9d..9553d9a 100644 --- a/internal/service/static/js/main.js +++ b/internal/service/static/js/main.js @@ -80,6 +80,7 @@ class Odidere { onSelectConversation: (id) => this.switchConversation(id), onRenameConversation: (id) => this.renameConversation(id), onDeleteConversation: (id) => this.deleteConversation(id), + onNewConversation: () => this.newConversation(), onPushToTalkStart: () => this.handlePushToTalkStart(), onPushToTalkEnd: () => this.handlePushToTalkEnd(), onTranscribeStart: () => this.handleTranscribeStart(), @@ -700,16 +701,7 @@ class Odidere { async reset() { this.tts.stop(); const id = this.conversationId; - // Clear the selection first, so the aborted run's cleanup does - // not open a new subscription to a conversation being deleted. - this.conversationId = null; - this.syncController?.abort(); - this.runController?.abort(); - if (this.currentRun) { - try { - await this.currentRun; - } catch {} - } + await this.detachConversation(); if (id) { try { await this.api.deleteConversation(id); @@ -742,6 +734,31 @@ class Odidere { this.updateSendEnabled(); } + /** + * detachConversation unselects the conversation, aborts any open + * sync or inference stream, and waits for an in-flight run to + * finish its cleanup. + * + * It does not stop the run on the server; it only ends local + * stream delivery. + * + * conversationId is cleared before the aborts so that the ending + * streams do not persist the cache, open a new sync stream, or + * fetch approvals for the old conversation. Awaiting currentRun + * lets run's finally block clear busy and drafts before the caller + * clears local state or loads another conversation. + */ + async detachConversation() { + this.conversationId = null; + this.syncController?.abort(); + this.runController?.abort(); + if (this.currentRun) { + try { + await this.currentRun; + } catch {} + } + } + // ==================== // CONVERSATIONS // ==================== @@ -873,23 +890,29 @@ class Odidere { } this.conversations = this.conversations.filter((c) => c.id !== id); if (id === this.conversationId) { - // The server-side delete cancels an ongoing inference. Detach - // the local streams before the state goes, so the aborted - // run's cleanup does not open a new subscription. + // The server-side delete cancels an ongoing inference. this.tts.stop(); - this.conversationId = null; - this.syncController?.abort(); - this.runController?.abort(); - if (this.currentRun) { - try { - await this.currentRun; - } catch {} - } + await this.detachConversation(); this.clearLocalConversation(); } this.filterConversations(this.conversationQuery); } + /** + * newConversation closes the picker and clears the view for a + * new conversation. It sends no request: send() creates the + * conversation when the next message goes out, so an abandoned + * click leaves no empty conversation on the server. A run in + * flight on the old conversation keeps running there. + */ + async newConversation() { + this.ui.closeConversations(); + if (!this.conversationId) return; + this.tts.stop(); + await this.detachConversation(); + this.clearLocalConversation(); + } + // ==================== // SYNC // ==================== diff --git a/internal/service/static/js/ui.js b/internal/service/static/js/ui.js index 857c359..c1c0e61 100644 --- a/internal/service/static/js/ui.js +++ b/internal/service/static/js/ui.js @@ -116,6 +116,7 @@ export class UI { ); this.$conversationsClose = document.getElementById('conversations-close'); this.$conversationsSearch = document.getElementById('conversation-search'); + this.$conversationNew = document.getElementById('conversation-new'); this.$conversationsList = document.getElementById('conversation-list'); this.$tplConversationItem = document.getElementById( 'tpl-conversation-item', @@ -1166,6 +1167,9 @@ export class UI { this.$conversationsSearch.addEventListener('input', () => this.handlers.onConversationSearch(this.$conversationsSearch.value), ); + this.$conversationNew.addEventListener('click', () => + this.handlers.onNewConversation(), + ); this.$conversationsList.addEventListener('click', (event) => { const $item = event.target.closest('[data-conversation-id]'); if (!$item) return; diff --git a/internal/service/templates/static/modal/conversations.gohtml b/internal/service/templates/static/modal/conversations.gohtml index 963fc5a..7a11e7e 100644 --- a/internal/service/templates/static/modal/conversations.gohtml +++ b/internal/service/templates/static/modal/conversations.gohtml @@ -25,6 +25,14 @@ aria-label="Search conversations" autocomplete="off" /> +