Add new conversation button
This commit is contained in:
@@ -999,6 +999,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.conversations-panel__search {
|
.conversations-panel__search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
padding: var(--s-1) var(--s1);
|
padding: var(--s-1) var(--s1);
|
||||||
border-bottom: 1px solid var(--color-border-light);
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -1006,7 +1009,7 @@ body {
|
|||||||
|
|
||||||
.conversations-search {
|
.conversations-search {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
flex: 1;
|
||||||
height: var(--s2);
|
height: var(--s2);
|
||||||
padding: 0 var(--s-1);
|
padding: 0 var(--s-1);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -1023,6 +1026,40 @@ body {
|
|||||||
outline-offset: 2px;
|
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 {
|
.conversations-panel__list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -146,4 +146,8 @@
|
|||||||
<path d="M19 17V5a2 2 0 0 0-2-2H4"/>
|
<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"/>
|
<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>
|
||||||
|
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.8 KiB |
@@ -80,6 +80,7 @@ class Odidere {
|
|||||||
onSelectConversation: (id) => this.switchConversation(id),
|
onSelectConversation: (id) => this.switchConversation(id),
|
||||||
onRenameConversation: (id) => this.renameConversation(id),
|
onRenameConversation: (id) => this.renameConversation(id),
|
||||||
onDeleteConversation: (id) => this.deleteConversation(id),
|
onDeleteConversation: (id) => this.deleteConversation(id),
|
||||||
|
onNewConversation: () => this.newConversation(),
|
||||||
onPushToTalkStart: () => this.handlePushToTalkStart(),
|
onPushToTalkStart: () => this.handlePushToTalkStart(),
|
||||||
onPushToTalkEnd: () => this.handlePushToTalkEnd(),
|
onPushToTalkEnd: () => this.handlePushToTalkEnd(),
|
||||||
onTranscribeStart: () => this.handleTranscribeStart(),
|
onTranscribeStart: () => this.handleTranscribeStart(),
|
||||||
@@ -700,16 +701,7 @@ class Odidere {
|
|||||||
async reset() {
|
async reset() {
|
||||||
this.tts.stop();
|
this.tts.stop();
|
||||||
const id = this.conversationId;
|
const id = this.conversationId;
|
||||||
// Clear the selection first, so the aborted run's cleanup does
|
await this.detachConversation();
|
||||||
// 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 {}
|
|
||||||
}
|
|
||||||
if (id) {
|
if (id) {
|
||||||
try {
|
try {
|
||||||
await this.api.deleteConversation(id);
|
await this.api.deleteConversation(id);
|
||||||
@@ -742,6 +734,31 @@ class Odidere {
|
|||||||
this.updateSendEnabled();
|
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
|
// CONVERSATIONS
|
||||||
// ====================
|
// ====================
|
||||||
@@ -873,23 +890,29 @@ class Odidere {
|
|||||||
}
|
}
|
||||||
this.conversations = this.conversations.filter((c) => c.id !== id);
|
this.conversations = this.conversations.filter((c) => c.id !== id);
|
||||||
if (id === this.conversationId) {
|
if (id === this.conversationId) {
|
||||||
// The server-side delete cancels an ongoing inference. Detach
|
// The server-side delete cancels an ongoing inference.
|
||||||
// the local streams before the state goes, so the aborted
|
|
||||||
// run's cleanup does not open a new subscription.
|
|
||||||
this.tts.stop();
|
this.tts.stop();
|
||||||
this.conversationId = null;
|
await this.detachConversation();
|
||||||
this.syncController?.abort();
|
|
||||||
this.runController?.abort();
|
|
||||||
if (this.currentRun) {
|
|
||||||
try {
|
|
||||||
await this.currentRun;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
this.clearLocalConversation();
|
this.clearLocalConversation();
|
||||||
}
|
}
|
||||||
this.filterConversations(this.conversationQuery);
|
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
|
// SYNC
|
||||||
// ====================
|
// ====================
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export class UI {
|
|||||||
);
|
);
|
||||||
this.$conversationsClose = document.getElementById('conversations-close');
|
this.$conversationsClose = document.getElementById('conversations-close');
|
||||||
this.$conversationsSearch = document.getElementById('conversation-search');
|
this.$conversationsSearch = document.getElementById('conversation-search');
|
||||||
|
this.$conversationNew = document.getElementById('conversation-new');
|
||||||
this.$conversationsList = document.getElementById('conversation-list');
|
this.$conversationsList = document.getElementById('conversation-list');
|
||||||
this.$tplConversationItem = document.getElementById(
|
this.$tplConversationItem = document.getElementById(
|
||||||
'tpl-conversation-item',
|
'tpl-conversation-item',
|
||||||
@@ -1166,6 +1167,9 @@ export class UI {
|
|||||||
this.$conversationsSearch.addEventListener('input', () =>
|
this.$conversationsSearch.addEventListener('input', () =>
|
||||||
this.handlers.onConversationSearch(this.$conversationsSearch.value),
|
this.handlers.onConversationSearch(this.$conversationsSearch.value),
|
||||||
);
|
);
|
||||||
|
this.$conversationNew.addEventListener('click', () =>
|
||||||
|
this.handlers.onNewConversation(),
|
||||||
|
);
|
||||||
this.$conversationsList.addEventListener('click', (event) => {
|
this.$conversationsList.addEventListener('click', (event) => {
|
||||||
const $item = event.target.closest('[data-conversation-id]');
|
const $item = event.target.closest('[data-conversation-id]');
|
||||||
if (!$item) return;
|
if (!$item) return;
|
||||||
|
|||||||
@@ -25,6 +25,14 @@
|
|||||||
aria-label="Search conversations"
|
aria-label="Search conversations"
|
||||||
autocomplete="off"
|
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>
|
||||||
|
|
||||||
<div class="conversations-panel__list" id="conversation-list"></div>
|
<div class="conversations-panel__list" id="conversation-list"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user