Add conversations modal

This commit is contained in:
dwrz
2026-10-30 23:06:25 +00:00
parent e1457b5130
commit 9020167a92
7 changed files with 429 additions and 1 deletions
+125
View File
@@ -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();