Add new conversation button

This commit is contained in:
dwrz
2026-10-31 17:20:37 +00:00
parent d9ce81af9c
commit 456d9ee6f3
5 changed files with 98 additions and 22 deletions
+38 -1
View File
@@ -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;
+4
View File
@@ -146,4 +146,8 @@
<path d="M19 17V5a2 2 0 0 0-2-2H4"/>
<path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>
</symbol>
<symbol id="plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"/>
<path d="M12 5v14"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

+44 -21
View File
@@ -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
// ====================
+4
View File
@@ -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;
@@ -25,6 +25,14 @@
aria-label="Search conversations"
autocomplete="off"
/>
<button
type="button"
class="conversations-new"
id="conversation-new"
aria-label="New conversation"
>
<svg class="icon"><use href="/static/icons.svg#plus"></use></svg>
</button>
</div>
<div class="conversations-panel__list" id="conversation-list"></div>