diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index 3b781d2..1617c4f 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -82,9 +82,20 @@ async def chat_index(request: Request, db: Db, user: RequiredUser, model: str = creating a row for a chat that may never be sent. """ context = _chat_context(db, user, None) - preselected = next( - (m for m in context["models"] if m.model_id == model), None - ) or (context["models"][0] if context["models"] else None) + + # Fall back to the same choice a new chat would make -- the user's default, + # then the instance default, then first in order. Using models[0] here + # instead would show a model the chat is not going to use, which matters: + # the composer decides from it whether to warn that images will be dropped. + preselected = next((m for m in context["models"] if m.model_id == model), None) + if preselected is None: + chosen = chat_service.default_model(db, user) + if chosen is not None: + preselected = next( + (m for m in context["models"] if m.model_id == chosen[0]), None + ) + if preselected is None and context["models"]: + preselected = context["models"][0] return render( request, diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index 126c664..0508dff 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -312,16 +312,24 @@ background: var(--bg); } .composer__inner { max-width: var(--thread-max-width); margin: 0 auto; } + +/* A column: chips on top, then the control row. The chips are inside the form + so their hidden file_ids inputs are submitted with the message. */ .composer__form { display: flex; - gap: var(--sp-1); - align-items: flex-end; + flex-direction: column; + gap: var(--sp-2); padding: var(--sp-2); border: 1px solid var(--border); border-radius: var(--radius-xl); background: var(--surface); transition: border-color var(--transition-fast), box-shadow var(--transition-fast); } +.composer__row { + display: flex; + gap: var(--sp-1); + align-items: flex-end; +} /* Attach and send are the same size and sit on the same baseline as the last line of the textarea, so the control row reads as one object. */ .composer__btn { flex: none; align-self: flex-end; border-radius: var(--radius-full); } @@ -386,10 +394,10 @@ .composer { position: relative; } .composer__attachments { - margin: 0 0 var(--sp-2); display: flex; flex-wrap: wrap; gap: var(--sp-2); + padding: var(--sp-1) var(--sp-1) 0; } .composer__attachments:empty { display: none; } diff --git a/src/lembas/web/static/js/app.js b/src/lembas/web/static/js/app.js index 1d620a1..bc7d254 100644 --- a/src/lembas/web/static/js/app.js +++ b/src/lembas/web/static/js/app.js @@ -107,15 +107,17 @@ rejection) then appears immediately, and a large file cannot make the send button appear to hang. */ function uploadFiles(fileList) { - var form = document.getElementById("upload-form"); + var input = document.getElementById("file-input"); var target = document.getElementById("attachments"); - if (!form || !target || !fileList || !fileList.length) return; + if (!input || !target || !fileList || !fileList.length) return; + + var url = input.dataset.uploadUrl; Array.prototype.forEach.call(fileList, function (file) { var body = new FormData(); body.append("file", file, file.name); - fetch(form.getAttribute("hx-post"), { method: "POST", body: body }) + fetch(url, { method: "POST", body: body, credentials: "same-origin" }) .then(function (response) { return response.text(); }) .then(function (html) { target.insertAdjacentHTML("beforeend", html); diff --git a/src/lembas/web/templates/chat/_composer.html b/src/lembas/web/templates/chat/_composer.html index fb419c7..d57df01 100644 --- a/src/lembas/web/templates/chat/_composer.html +++ b/src/lembas/web/templates/chat/_composer.html @@ -8,21 +8,23 @@ redirects, and the reply streams on arrival because the page renders the unfinished assistant message with its sse-connect. That is what stops an opened-and-abandoned chat ever being written to the database. + + The attachment chips live INSIDE this form on purpose. Each carries a hidden + file_ids input, and being inside the form is what gets them serialised with + the message. Keeping them outside and reaching for hx-include does not work: + that attribute only has an effect on the element issuing the request. #}
diff --git a/tests/test_files.py b/tests/test_files.py index 354ad5e..e4ef42d 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -495,3 +495,98 @@ def test_deleting_a_message_deletes_its_attachments(client: TestClient, db, chat client.delete(f"/api/chats/{chat_with_model}") assert db.scalar(select(Attachment)) is None + + +# --- Composer wiring --------------------------------------------------------- +# These assert on the rendered HTML rather than on behaviour, because the bug +# they guard against lives entirely in the template: every server-side test +# passed while the browser silently never sent file_ids at all. +def test_the_attachments_container_is_inside_the_composer_form( + client: TestClient, db, chat_with_model +): + """The chips carry the hidden file_ids inputs. Outside the form they are + not serialised, and hx-include does not help -- it only has an effect on + the element issuing the request, not on a child of it.""" + import re + + page = client.get(f"/chat/{chat_with_model}").text + form = re.search(r'