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"
/>
+