diff --git a/internal/db/queries.go b/internal/db/queries.go index 57cbc4d..ca35a86 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -286,6 +286,64 @@ func (q *Queries) SelectConversationsUserId(ctx context.Context, userId string) return conversations, nil } +const updateConversationTitle = ` +UPDATE conversations +SET title = ? +WHERE id = ? +RETURNING created_at, id, title, updated_at, user_id +` + +// UpdateConversationTitleParams holds arguments for +// UpdateConversationTitle. +type UpdateConversationTitleParams struct { + Id string + Title string +} + +// UpdateConversationTitle sets the title of one conversation and +// returns the updated row. +// +// The update keeps updated_at. That timestamp is the +// conversation's synchronization token for its message set, and a +// title is not a message: a rename must not move the conversation +// in the picker's ordering, which sorts by updated_at. +// +// A conversation that is missing returns sql.ErrNoRows. +func (q *Queries) UpdateConversationTitle( + ctx context.Context, + arg UpdateConversationTitleParams, +) (*Conversation, error) { + row := q.db.QueryRowContext( + ctx, + updateConversationTitle, + arg.Title, + arg.Id, + ) + + var ( + c Conversation + createdAt string + updatedAt string + err error + ) + if err := row.Scan( + &createdAt, + &c.Id, + &c.Title, + &updatedAt, + &c.UserId, + ); err != nil { + return nil, fmt.Errorf("UpdateConversationTitle: %w", err) + } + if c.CreatedAt, err = time.Parse(time.RFC3339, createdAt); err != nil { + return nil, fmt.Errorf("UpdateConversationTitle: parse created_at: %w", err) + } + if c.UpdatedAt, err = time.Parse(time.RFC3339, updatedAt); err != nil { + return nil, fmt.Errorf("UpdateConversationTitle: parse updated_at: %w", err) + } + return &c, nil +} + const deleteConversationId = ` DELETE FROM conversations WHERE id = ? diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index a9991cd..cf0d0cb 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -2,6 +2,7 @@ package db import ( "context" + "database/sql" "encoding/json/jsontext" "encoding/json/v2" "errors" @@ -134,6 +135,46 @@ func testConversation(t *testing.T, ctx context.Context, db *SQLite) { if len(conversations) != 1 { t.Fatalf("expected 1 conversation, got %d", len(conversations)) } + + // Rename the conversation. Insert a message first: the insert + // trigger moves updated_at, so the rename can show that it + // keeps the timestamp. + if _, err := qw.InsertMessage(ctx, InsertMessageParams{ + ConversationId: c.Id, + Sequence: 1, + Role: MessageRoleUser, + Content: jsontext.Value(`{"role":"user","content":"Hello"}`), + }); err != nil { + t.Fatalf("InsertMessage: %v", err) + } + before, err := qr.SelectConversationId(ctx, c.Id) + if err != nil { + t.Fatalf("SelectConversationId: %v", err) + } + renamed, err := qw.UpdateConversationTitle(ctx, UpdateConversationTitleParams{ + Id: c.Id, + Title: "renamed", + }) + if err != nil { + t.Fatalf("UpdateConversationTitle: %v", err) + } + if renamed.Title == nil || *renamed.Title != "renamed" { + t.Fatalf("Title = %v, want renamed", renamed.Title) + } + if !renamed.UpdatedAt.Equal(before.UpdatedAt) { + t.Fatalf( + "UpdatedAt = %v, want %v: a rename must not move updated_at", + renamed.UpdatedAt, before.UpdatedAt, + ) + } + + // A rename of a missing conversation reports no rows. + if _, err := qw.UpdateConversationTitle(ctx, UpdateConversationTitleParams{ + Id: "missing", + Title: "renamed", + }); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("UpdateConversationTitle(missing) = %v, want sql.ErrNoRows", err) + } } func testMessaging(t *testing.T, ctx context.Context, db *SQLite) { diff --git a/internal/service/conversations.go b/internal/service/conversations.go index 7eb67f4..7baac13 100644 --- a/internal/service/conversations.go +++ b/internal/service/conversations.go @@ -6,6 +6,10 @@ import ( "errors" "log/slog" "net/http" + "strings" + "unicode/utf8" + + "code.chimeric.al/chimerical/odidere/internal/db" ) func (svc *Service) listConversations(w http.ResponseWriter, r *http.Request) { @@ -125,6 +129,103 @@ func (svc *Service) getConversationHandler( } } +// maxTitleLength is the most characters, counted as Unicode code +// points, that a conversation title may hold. It keeps a title +// short enough for the picker and small in storage. +const maxTitleLength = 256 + +// updateConversation renames one conversation. The handler trims +// the title, then rejects an empty title or a title longer than +// maxTitleLength with 400. A conversation that does not exist, or +// belongs to another user, returns 404. The rename is allowed while +// inference runs: a title is not a message, and a concurrent +// inference does not read or write it. +func (svc *Service) updateConversation(w http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + log = ctx.Value(logKey).(*slog.Logger) + id = r.PathValue("id") + req struct { + Title string `json:"title"` + } + ) + + r.Body = http.MaxBytesReader(w, r.Body, 32<<20) + if err := json.UnmarshalRead(r.Body, &req); err != nil { + log.ErrorContext( + ctx, "failed to decode request", + slog.Any("error", err), + ) + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + req.Title = strings.TrimSpace(req.Title) + if req.Title == "" { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + if utf8.RuneCountInString(req.Title) > maxTitleLength { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + // Load the conversation first, so a rename of a conversation + // that does not belong to the user reports 404, not a silent + // no-op. + if _, err := svc.dbr.SelectConversationIdUserId( + ctx, id, "default", + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.NotFound(w, r) + return + } + log.ErrorContext( + ctx, "failed to load conversation", + slog.Any("error", err), + ) + http.Error( + w, + http.StatusText(http.StatusInternalServerError), + http.StatusInternalServerError, + ) + return + } + + saved, err := svc.dbw.UpdateConversationTitle( + ctx, + db.UpdateConversationTitleParams{ + Id: id, + Title: req.Title, + }, + ) + if err != nil { + // The conversation was deleted after the load above. + if errors.Is(err, sql.ErrNoRows) { + http.NotFound(w, r) + return + } + log.ErrorContext( + ctx, "failed to rename conversation", + slog.Any("error", err), + ) + http.Error( + w, + http.StatusText(http.StatusInternalServerError), + http.StatusInternalServerError, + ) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.MarshalWrite(w, saved); err != nil { + log.ErrorContext( + ctx, "failed to encode conversation response", + slog.Any("error", err), + ) + } +} + func (svc *Service) deleteConversation(w http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() diff --git a/internal/service/service.go b/internal/service/service.go index c3faba7..4e5076e 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -218,6 +218,9 @@ func New( svc.mux.HandleFunc("GET /conversations", svc.listConversations) svc.mux.HandleFunc("POST /conversations", svc.createConversation) svc.mux.HandleFunc("GET /conversations/{id}", svc.getConversationHandler) + svc.mux.HandleFunc( + "PATCH /conversations/{id}", svc.updateConversation, + ) svc.mux.HandleFunc("DELETE /conversations/{id}", svc.deleteConversation) svc.mux.HandleFunc( diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 063b59f..d069a74 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -135,6 +135,53 @@ func TestConversationHandlers(t *testing.T) { t.Fatalf("get status = %d, want 200", w.Code) } + // Rename the conversation, then try renames the server must + // reject. + w = httptest.NewRecorder() + svc.ServeHTTP(w, httptest.NewRequest( + http.MethodPatch, "/conversations/"+created.Id, + strings.NewReader(`{"title":"Renamed"}`), + )) + if w.Code != http.StatusOK { + t.Fatalf("rename status = %d, want 200: %s", w.Code, w.Body.String()) + } + var renamed db.Conversation + if err := json.Unmarshal(w.Body.Bytes(), &renamed); err != nil { + t.Fatalf("Unmarshal renamed: %v", err) + } + if renamed.Title == nil || *renamed.Title != "Renamed" { + t.Fatalf("Title = %v, want Renamed", renamed.Title) + } + + w = httptest.NewRecorder() + svc.ServeHTTP(w, httptest.NewRequest( + http.MethodPatch, "/conversations/missing", + strings.NewReader(`{"title":"Renamed"}`), + )) + if w.Code != http.StatusNotFound { + t.Fatalf("rename missing status = %d, want 404", w.Code) + } + + w = httptest.NewRecorder() + svc.ServeHTTP(w, httptest.NewRequest( + http.MethodPatch, "/conversations/"+created.Id, + strings.NewReader(`{"title":" "}`), + )) + if w.Code != http.StatusBadRequest { + t.Fatalf("rename blank status = %d, want 400", w.Code) + } + + w = httptest.NewRecorder() + svc.ServeHTTP(w, httptest.NewRequest( + http.MethodPatch, "/conversations/"+created.Id, + strings.NewReader(fmt.Sprintf( + `{"title":%q}`, strings.Repeat("x", 257), + )), + )) + if w.Code != http.StatusBadRequest { + t.Fatalf("rename long status = %d, want 400", w.Code) + } + w = httptest.NewRecorder() svc.ServeHTTP(w, httptest.NewRequest( http.MethodDelete, "/conversations/"+created.Id, nil, diff --git a/internal/service/static/css/main.css b/internal/service/static/css/main.css index 02d4975..381ad4c 100644 --- a/internal/service/static/css/main.css +++ b/internal/service/static/css/main.css @@ -1029,11 +1029,34 @@ body { padding: var(--s-1) var(--s1); } +/* Each row is a container with two parts: the select button that + holds the title and the dates, and the small action buttons + (rename, delete) on the right. A row is not one button, because + a button may not hold other buttons. */ .conversations__item { + display: flex; + align-items: center; + gap: var(--s-2); + width: 100%; + border-radius: var(--radius); +} + +@media (hover: hover) { + .conversations__item:hover { + background: var(--color-gray1); + } +} + +.conversations__item--current { + background: var(--color-gray1); +} + +.conversations__item-main { display: flex; flex-direction: column; gap: var(--s-2); - width: 100%; + flex: 1; + min-width: 0; padding: var(--s-1); font-family: inherit; text-align: left; @@ -1043,19 +1066,52 @@ body { cursor: pointer; } -@media (hover: hover) { - .conversations__item:hover { - background: var(--color-gray1); - } -} - -.conversations__item:focus-visible { +.conversations__item-main:focus-visible { outline: 2px solid var(--color-primary); outline-offset: -2px; } -.conversations__item--current { - background: var(--color-gray1); +.conversations__item-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0.125rem; + background: transparent; + border: none; + border-radius: 0.125rem; + color: var(--color-text-muted); + cursor: pointer; +} + +.conversations__item-btn svg { + width: var(--action-icon-size); + height: var(--action-icon-size); +} + +.conversations__item-btn:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 1px; +} + +@media (hover: hover) { + .conversations__item-btn:hover { + color: var(--color-text); + } + + .conversations__item-delete:hover { + color: var(--color-red); + } +} + +/* Pending delete: yellow highlight, press again to confirm */ +.conversations__item-delete.pending { + color: var(--color-yellow); +} + +@media (hover: hover) { + .conversations__item-delete.pending:hover { + color: var(--color-yellow); + } } .conversations__item-title { @@ -1066,6 +1122,22 @@ body { white-space: nowrap; } +/* The two dates share one line on desktop screens. Narrow screens + stack them, so each date fits without clipping. */ +.conversations__item-dates { + display: flex; + align-items: baseline; + gap: var(--s-1); +} + +@media (max-width: 639px) { + .conversations__item-dates { + flex-direction: column; + gap: 0; + } +} + +.conversations__item-created, .conversations__item-updated { font-size: var(--s-1); font-family: var(--font-mono); @@ -1198,6 +1270,24 @@ body { border-color: var(--color-primary); } +/* Single-line input for the rename modal. It shares the error + marker class with the textareas; see modal__textarea--error. */ +.modal__input { + width: 100%; + padding: var(--s-1); + font-family: inherit; + font-size: var(--s0); + color: var(--color-text); + background: var(--color-bg); + border: var(--border-width) solid var(--color-border-light); + border-radius: var(--radius); + outline: none; +} + +.modal__input:focus { + border-color: var(--color-primary); +} + .modal__textarea--error { border-color: var(--color-red); } diff --git a/internal/service/static/js/api.js b/internal/service/static/js/api.js index d1603c9..68aa8d5 100644 --- a/internal/service/static/js/api.js +++ b/internal/service/static/js/api.js @@ -90,6 +90,22 @@ export class Api { return this._json('/conversations', { method: 'POST', body }); } + /** + * renameConversation sets a new title for a conversation. The + * server trims the title and rejects an empty one. The returned + * row holds the saved title; the caller uses it to update the + * picker. + * @param {string} id + * @param {string} title + * @returns {Promise} the saved conversation row. + */ + async renameConversation(id, title) { + return this._json(`/conversations/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: { title }, + }); + } + /** * deleteConversation removes the conversation, all its messages, * and its tool execution records. It also cancels an ongoing @@ -199,7 +215,7 @@ export class Api { async updateMessage(id, mid, body) { return this._json( `/conversations/${encodeURIComponent(id)}` + - `/messages/${encodeURIComponent(mid)}`, + `/messages/${encodeURIComponent(mid)}`, { method: 'PUT', body }, ); } @@ -215,7 +231,7 @@ export class Api { async deleteMessage(id, mid, updatedAt) { await this._request( `/conversations/${encodeURIComponent(id)}` + - `/messages/${encodeURIComponent(mid)}`, + `/messages/${encodeURIComponent(mid)}`, { method: 'DELETE', body: { updated_at: updatedAt } }, ); } @@ -234,8 +250,8 @@ export class Api { async updateToolCall(id, mid, cid, body) { return this._json( `/conversations/${encodeURIComponent(id)}` + - `/messages/${encodeURIComponent(mid)}` + - `/calls/${encodeURIComponent(cid)}`, + `/messages/${encodeURIComponent(mid)}` + + `/calls/${encodeURIComponent(cid)}`, { method: 'PUT', body }, ); } @@ -252,8 +268,8 @@ export class Api { async deleteToolCall(id, mid, cid, updatedAt) { await this._request( `/conversations/${encodeURIComponent(id)}` + - `/messages/${encodeURIComponent(mid)}` + - `/calls/${encodeURIComponent(cid)}`, + `/messages/${encodeURIComponent(mid)}` + + `/calls/${encodeURIComponent(cid)}`, { method: 'DELETE', body: { updated_at: updatedAt } }, ); } @@ -380,7 +396,7 @@ export async function* readEvents(response) { if (result.done) break; } } finally { - await reader.cancel().catch(() => { }); + await reader.cancel().catch(() => {}); reader.releaseLock(); } } diff --git a/internal/service/static/js/main.js b/internal/service/static/js/main.js index 164a944..7068c9d 100644 --- a/internal/service/static/js/main.js +++ b/internal/service/static/js/main.js @@ -69,9 +69,17 @@ class Odidere { onSettingsOpen: () => this.ui.setSystemMessageDraft(this.storage.getSystemMessage()), onSaveSystemMessage: (text) => this.storage.setSystemMessage(text), - onConversationsOpen: () => this.loadConversations(), - onConversationSearch: (query) => this.filterConversations(query), + onConversationsOpen: () => { + this.conversationQuery = ''; + this.loadConversations(); + }, + onConversationSearch: (query) => { + this.conversationQuery = query; + this.filterConversations(query); + }, onSelectConversation: (id) => this.switchConversation(id), + onRenameConversation: (id) => this.renameConversation(id), + onDeleteConversation: (id) => this.deleteConversation(id), onPushToTalkStart: () => this.handlePushToTalkStart(), onPushToTalkEnd: () => this.handlePushToTalkEnd(), onTranscribeStart: () => this.handleTranscribeStart(), @@ -111,6 +119,9 @@ class Odidere { this.conversationId = null; // The rows the conversation picker shows, fetched on open. this.conversations = []; + // The picker's current search text. A rename or delete keeps + // it, so the list re-renders with the same filter. + this.conversationQuery = ''; this.messages = new Map(); this.drafts = new Map(); this.busy = false; @@ -697,7 +708,7 @@ class Odidere { if (this.currentRun) { try { await this.currentRun; - } catch { } + } catch {} } if (id) { try { @@ -708,6 +719,16 @@ class Odidere { this.ui.renderError(`Failed to delete conversation: ${error.message}`); } } + this.clearLocalConversation(); + } + + /** + * clearLocalConversation drops every local trace of the selected + * conversation: the cached messages, the drafts, the storage, and + * the chat view. It sends no request. reset and the picker's + * delete call it once the conversation is gone on the server. + */ + clearLocalConversation() { this.messages.clear(); this.drafts.clear(); this.storage.clearConversation(); @@ -794,7 +815,7 @@ class Odidere { if (this.currentRun) { try { await this.currentRun; - } catch { } + } catch {} } } @@ -814,6 +835,61 @@ class Odidere { this.updateSendEnabled(); } + /** + * renameConversation asks the user for a new title, saves it, and + * re-renders the picker with the current search filter. The saved + * row comes from the server, so the list shows exactly what was + * stored. + * @param {string} id + */ + async renameConversation(id) { + const row = this.conversations.find((c) => c.id === id); + if (!row) return; + const title = await this.ui.editConversationTitle(row.title ?? ''); + if (title == null) return; + try { + const saved = await this.api.renameConversation(id, title); + row.title = saved.title; + } catch (error) { + this.ui.renderError(`Failed to rename conversation: ${error.message}`); + return; + } + this.filterConversations(this.conversationQuery); + } + + /** + * deleteConversation removes one conversation from the server and + * from the picker. The two-press confirmation in the UI is the + * only guard. Deleting the conversation in view also clears the + * local state, like reset does. + * @param {string} id + */ + async deleteConversation(id) { + try { + await this.api.deleteConversation(id); + } catch (error) { + this.ui.renderError(`Failed to delete conversation: ${error.message}`); + return; + } + 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. + this.tts.stop(); + this.conversationId = null; + this.syncController?.abort(); + this.runController?.abort(); + if (this.currentRun) { + try { + await this.currentRun; + } catch {} + } + this.clearLocalConversation(); + } + this.filterConversations(this.conversationQuery); + } + // ==================== // SYNC // ==================== @@ -1457,7 +1533,7 @@ class Odidere { this.ui.setLocationActive(true); this.updateSendEnabled(); }, - () => { }, + () => {}, GEOLOCATION_OPTIONS, ); } diff --git a/internal/service/static/js/ui.js b/internal/service/static/js/ui.js index 57b19fc..daefbed 100644 --- a/internal/service/static/js/ui.js +++ b/internal/service/static/js/ui.js @@ -120,6 +120,7 @@ export class UI { this.$tplConversationItem = document.getElementById( 'tpl-conversation-item', ); + this.$tplRenameModal = document.getElementById('tpl-rename-modal'); // Compose settings modal. this.$composeSettings = document.getElementById('compose-settings'); @@ -904,6 +905,10 @@ export class UI { $item.dataset.conversationId = conversation.id; $item.querySelector('.conversations__item-title').textContent = conversationLabel(conversation); + $item.querySelector('.conversations__item-created').textContent = + `Created ${CONVERSATION_DATE.format( + new Date(conversation.created_at), + )}`; $item.querySelector('.conversations__item-updated').textContent = `Updated ${CONVERSATION_DATE.format( new Date(conversation.updated_at), @@ -951,6 +956,27 @@ export class UI { ); } + /** + * editConversationTitle opens the rename modal with the current + * title. It resolves with the trimmed new title when saved, and + * with null when cancelled. An empty title is not a rename, so + * the modal stays open until the user enters one or cancels. + * @param {string} title + * @returns {Promise} + */ + editConversationTitle(title) { + const $modal = + this.$tplRenameModal.content.cloneNode(true).firstElementChild; + $modal.querySelector('#rename-input').value = title; + return this._openModal($modal, ($modal) => { + const value = $modal.querySelector('#rename-input').value.trim(); + if (value === '') { + throw new Error('the title must not be empty'); + } + return value; + }); + } + /** * editToolCall opens the tool edit modal, which edits the call's * name and arguments and, when a result exists, its text. It @@ -1143,7 +1169,24 @@ export class UI { this.$conversationsList.addEventListener('click', (event) => { const $item = event.target.closest('[data-conversation-id]'); if (!$item) return; - this.handlers.onSelectConversation($item.dataset.conversationId); + const id = $item.dataset.conversationId; + const $rename = event.target.closest('.conversations__item-rename'); + if ($rename) { + this._clearConfirm(); + this.handlers.onRenameConversation(id); + return; + } + const $delete = event.target.closest('.conversations__item-delete'); + if ($delete) { + // Deleting is irreversible: the second press on the same + // button deletes, any other click cancels the request. + this._confirm(`conversation:${id}`, $delete, 'pending', () => + this.handlers.onDeleteConversation(id), + ); + return; + } + this._clearConfirm(); + this.handlers.onSelectConversation(id); }); // Compose settings modal. @@ -1474,15 +1517,17 @@ export class UI { value = readValue($modal); } catch (error) { console.error('invalid input:', error); - for (const $textarea of $modal.querySelectorAll('.modal__textarea')) { - $textarea.classList.add('modal__textarea--error'); + for (const $field of $modal.querySelectorAll( + '.modal__textarea, .modal__input', + )) { + $field.classList.add('modal__textarea--error'); } $save.classList.add('modal__save--error'); setTimeout(() => { - for (const $textarea of $modal.querySelectorAll( - '.modal__textarea', + for (const $field of $modal.querySelectorAll( + '.modal__textarea, .modal__input', )) { - $textarea.classList.remove('modal__textarea--error'); + $field.classList.remove('modal__textarea--error'); } $save.classList.remove('modal__save--error'); }, 500); @@ -1491,7 +1536,7 @@ export class UI { close(value); }); - $modal.querySelector('textarea')?.focus(); + $modal.querySelector('textarea, input')?.focus(); }); } diff --git a/internal/service/templates/static/modal/conversations.gohtml b/internal/service/templates/static/modal/conversations.gohtml index 0fa6af4..963fc5a 100644 --- a/internal/service/templates/static/modal/conversations.gohtml +++ b/internal/service/templates/static/modal/conversations.gohtml @@ -32,9 +32,51 @@ + + {{ end }}