Delete and rename conversations

This commit is contained in:
dwrz
2026-10-31 12:13:36 +00:00
parent 9020167a92
commit 62bb1cbf32
10 changed files with 552 additions and 33 deletions
+58
View File
@@ -286,6 +286,64 @@ func (q *Queries) SelectConversationsUserId(ctx context.Context, userId string)
return conversations, nil 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 = ` const deleteConversationId = `
DELETE FROM conversations DELETE FROM conversations
WHERE id = ? WHERE id = ?
+41
View File
@@ -2,6 +2,7 @@ package db
import ( import (
"context" "context"
"database/sql"
"encoding/json/jsontext" "encoding/json/jsontext"
"encoding/json/v2" "encoding/json/v2"
"errors" "errors"
@@ -134,6 +135,46 @@ func testConversation(t *testing.T, ctx context.Context, db *SQLite) {
if len(conversations) != 1 { if len(conversations) != 1 {
t.Fatalf("expected 1 conversation, got %d", len(conversations)) 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) { func testMessaging(t *testing.T, ctx context.Context, db *SQLite) {
+101
View File
@@ -6,6 +6,10 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"unicode/utf8"
"code.chimeric.al/chimerical/odidere/internal/db"
) )
func (svc *Service) listConversations(w http.ResponseWriter, r *http.Request) { 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) { func (svc *Service) deleteConversation(w http.ResponseWriter, r *http.Request) {
var ( var (
ctx = r.Context() ctx = r.Context()
+3
View File
@@ -218,6 +218,9 @@ func New(
svc.mux.HandleFunc("GET /conversations", svc.listConversations) svc.mux.HandleFunc("GET /conversations", svc.listConversations)
svc.mux.HandleFunc("POST /conversations", svc.createConversation) svc.mux.HandleFunc("POST /conversations", svc.createConversation)
svc.mux.HandleFunc("GET /conversations/{id}", svc.getConversationHandler) 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("DELETE /conversations/{id}", svc.deleteConversation)
svc.mux.HandleFunc( svc.mux.HandleFunc(
+47
View File
@@ -135,6 +135,53 @@ func TestConversationHandlers(t *testing.T) {
t.Fatalf("get status = %d, want 200", w.Code) 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() w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest( svc.ServeHTTP(w, httptest.NewRequest(
http.MethodDelete, "/conversations/"+created.Id, nil, http.MethodDelete, "/conversations/"+created.Id, nil,
+100 -10
View File
@@ -1029,11 +1029,34 @@ body {
padding: var(--s-1) var(--s1); 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 { .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; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--s-2); gap: var(--s-2);
width: 100%; flex: 1;
min-width: 0;
padding: var(--s-1); padding: var(--s-1);
font-family: inherit; font-family: inherit;
text-align: left; text-align: left;
@@ -1043,19 +1066,52 @@ body {
cursor: pointer; cursor: pointer;
} }
@media (hover: hover) { .conversations__item-main:focus-visible {
.conversations__item:hover {
background: var(--color-gray1);
}
}
.conversations__item:focus-visible {
outline: 2px solid var(--color-primary); outline: 2px solid var(--color-primary);
outline-offset: -2px; outline-offset: -2px;
} }
.conversations__item--current { .conversations__item-btn {
background: var(--color-gray1); 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 { .conversations__item-title {
@@ -1066,6 +1122,22 @@ body {
white-space: nowrap; 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 { .conversations__item-updated {
font-size: var(--s-1); font-size: var(--s-1);
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -1198,6 +1270,24 @@ body {
border-color: var(--color-primary); 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 { .modal__textarea--error {
border-color: var(--color-red); border-color: var(--color-red);
} }
+16
View File
@@ -90,6 +90,22 @@ export class Api {
return this._json('/conversations', { method: 'POST', body }); 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<Object>} 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, * deleteConversation removes the conversation, all its messages,
* and its tool execution records. It also cancels an ongoing * and its tool execution records. It also cancels an ongoing
+78 -2
View File
@@ -69,9 +69,17 @@ class Odidere {
onSettingsOpen: () => onSettingsOpen: () =>
this.ui.setSystemMessageDraft(this.storage.getSystemMessage()), this.ui.setSystemMessageDraft(this.storage.getSystemMessage()),
onSaveSystemMessage: (text) => this.storage.setSystemMessage(text), onSaveSystemMessage: (text) => this.storage.setSystemMessage(text),
onConversationsOpen: () => this.loadConversations(), onConversationsOpen: () => {
onConversationSearch: (query) => this.filterConversations(query), this.conversationQuery = '';
this.loadConversations();
},
onConversationSearch: (query) => {
this.conversationQuery = query;
this.filterConversations(query);
},
onSelectConversation: (id) => this.switchConversation(id), onSelectConversation: (id) => this.switchConversation(id),
onRenameConversation: (id) => this.renameConversation(id),
onDeleteConversation: (id) => this.deleteConversation(id),
onPushToTalkStart: () => this.handlePushToTalkStart(), onPushToTalkStart: () => this.handlePushToTalkStart(),
onPushToTalkEnd: () => this.handlePushToTalkEnd(), onPushToTalkEnd: () => this.handlePushToTalkEnd(),
onTranscribeStart: () => this.handleTranscribeStart(), onTranscribeStart: () => this.handleTranscribeStart(),
@@ -111,6 +119,9 @@ class Odidere {
this.conversationId = null; this.conversationId = null;
// The rows the conversation picker shows, fetched on open. // The rows the conversation picker shows, fetched on open.
this.conversations = []; 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.messages = new Map();
this.drafts = new Map(); this.drafts = new Map();
this.busy = false; this.busy = false;
@@ -708,6 +719,16 @@ class Odidere {
this.ui.renderError(`Failed to delete conversation: ${error.message}`); 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.messages.clear();
this.drafts.clear(); this.drafts.clear();
this.storage.clearConversation(); this.storage.clearConversation();
@@ -814,6 +835,61 @@ class Odidere {
this.updateSendEnabled(); 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 // SYNC
// ==================== // ====================
+52 -7
View File
@@ -120,6 +120,7 @@ export class UI {
this.$tplConversationItem = document.getElementById( this.$tplConversationItem = document.getElementById(
'tpl-conversation-item', 'tpl-conversation-item',
); );
this.$tplRenameModal = document.getElementById('tpl-rename-modal');
// Compose settings modal. // Compose settings modal.
this.$composeSettings = document.getElementById('compose-settings'); this.$composeSettings = document.getElementById('compose-settings');
@@ -904,6 +905,10 @@ export class UI {
$item.dataset.conversationId = conversation.id; $item.dataset.conversationId = conversation.id;
$item.querySelector('.conversations__item-title').textContent = $item.querySelector('.conversations__item-title').textContent =
conversationLabel(conversation); conversationLabel(conversation);
$item.querySelector('.conversations__item-created').textContent =
`Created ${CONVERSATION_DATE.format(
new Date(conversation.created_at),
)}`;
$item.querySelector('.conversations__item-updated').textContent = $item.querySelector('.conversations__item-updated').textContent =
`Updated ${CONVERSATION_DATE.format( `Updated ${CONVERSATION_DATE.format(
new Date(conversation.updated_at), 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<string | null>}
*/
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 * editToolCall opens the tool edit modal, which edits the call's
* name and arguments and, when a result exists, its text. It * name and arguments and, when a result exists, its text. It
@@ -1143,7 +1169,24 @@ export class UI {
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;
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. // Compose settings modal.
@@ -1474,15 +1517,17 @@ export class UI {
value = readValue($modal); value = readValue($modal);
} catch (error) { } catch (error) {
console.error('invalid input:', error); console.error('invalid input:', error);
for (const $textarea of $modal.querySelectorAll('.modal__textarea')) { for (const $field of $modal.querySelectorAll(
$textarea.classList.add('modal__textarea--error'); '.modal__textarea, .modal__input',
)) {
$field.classList.add('modal__textarea--error');
} }
$save.classList.add('modal__save--error'); $save.classList.add('modal__save--error');
setTimeout(() => { setTimeout(() => {
for (const $textarea of $modal.querySelectorAll( for (const $field of $modal.querySelectorAll(
'.modal__textarea', '.modal__textarea, .modal__input',
)) { )) {
$textarea.classList.remove('modal__textarea--error'); $field.classList.remove('modal__textarea--error');
} }
$save.classList.remove('modal__save--error'); $save.classList.remove('modal__save--error');
}, 500); }, 500);
@@ -1491,7 +1536,7 @@ export class UI {
close(value); close(value);
}); });
$modal.querySelector('textarea')?.focus(); $modal.querySelector('textarea, input')?.focus();
}); });
} }
@@ -32,9 +32,51 @@
</div> </div>
<template id="tpl-conversation-item"> <template id="tpl-conversation-item">
<button type="button" class="conversations__item"> <div class="conversations__item">
<button type="button" class="conversations__item-main">
<span class="conversations__item-title"></span> <span class="conversations__item-title"></span>
<span class="conversations__item-dates">
<span class="conversations__item-created"></span>
<span class="conversations__item-updated"></span> <span class="conversations__item-updated"></span>
</span>
</button> </button>
<button
type="button"
class="conversations__item-btn conversations__item-rename"
aria-label="Rename conversation"
>
<svg class="icon"><use href="/static/icons.svg#edit"></use></svg>
</button>
<button
type="button"
class="conversations__item-btn conversations__item-delete"
aria-label="Delete conversation"
>
<svg class="icon"><use href="/static/icons.svg#trash"></use></svg>
</button>
</div>
</template>
<template id="tpl-rename-modal">
<div class="modal">
<div class="modal__header">
<h2>Rename Conversation</h2>
<button type="button" class="modal__close" aria-label="Cancel">
<svg class="icon"><use href="/static/icons.svg#close"></use></svg>
</button>
</div>
<div class="modal__body">
<input
type="text"
class="modal__input"
id="rename-input"
maxlength="256"
autocomplete="off"
/>
</div>
<div class="modal__footer">
<button type="button" class="modal__save" id="rename-save">Save</button>
</div>
</div>
</template> </template>
{{ end }} {{ end }}