A phone, and how much of this could not be used on one
The sidebar was a 280px panel laid over the page below the phone breakpoint, opened from first paint, with the only control that closed it underneath it -- and that control existed on /chat and on none of the seven other pages carrying a sidebar, Settings included. It starts closed at that width now, slides, dims the page behind it, and closes by tapping beside it, by Escape, or by its own button, which is inside the drawer where it can be reached. Everything a finger has to hit was 36px, or 28 for renaming a chat, every action on a message and every panel's close button. Raising --control-h under a coarse pointer is the only fix that reaches all forty of them, which is what that token is for. The row and message actions were also hover-only, so on a phone they did not exist at all. Installing: the splash and the browser chrome follow the instance's theme rather than always being Moria's near-black; there are screenshots, so the install offer is a dialog rather than a one-line bar; a new release no longer takes over a page somebody is reading; the notification badge is a silhouette rather than a grey square; and a browser rotating its own subscription no longer ends notifications for good. Every request now says it is happening -- nothing did before, so anything slower than a few milliseconds looked like a click that had not registered. A chat can be archived. The column has been filtered on in four places since folders arrived and written by nothing, which is what made it look built. chat.css may contain media queries. The ban protected the composer toolbar from being "fixed" with a breakpoint; that guarantee is asserted directly now, and the old test would have passed a version of the file that wrapped the toolbar without one. scripts/shoot.py is the instrument all of this was found with: it renders a page through TestClient into a real headless browser at a real size and refuses to run if an asset URL was left pointing at testserver. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
|
||||
@@ -103,6 +104,25 @@ async def connections_page(request: Request, db: Db, user: AdminUser, message: s
|
||||
)
|
||||
|
||||
|
||||
# Header names are a narrow set on purpose: a newline would let one field write
|
||||
# a second header, and a colon in a name splits it. Anything outside it is
|
||||
# dropped rather than repaired -- a header nobody can see the effect of is worse
|
||||
# than one that is visibly missing.
|
||||
_HEADER_NAME = re.compile(r"^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$")
|
||||
|
||||
|
||||
def _parse_headers(raw: str) -> dict[str, str]:
|
||||
"""`Name: value` per line, into the dict the client sends verbatim."""
|
||||
headers: dict[str, str] = {}
|
||||
for line in (raw or "").splitlines()[:20]:
|
||||
name, _, value = line.partition(":")
|
||||
name = name.strip()
|
||||
value = value.strip()[:500]
|
||||
if name and value and _HEADER_NAME.match(name):
|
||||
headers[name] = value
|
||||
return headers
|
||||
|
||||
|
||||
@router.post("/connections")
|
||||
async def create_connection(
|
||||
db: Db,
|
||||
@@ -145,6 +165,7 @@ async def update_connection(
|
||||
enabled: bool = Form(False),
|
||||
unload_url: str = Form(""),
|
||||
unload_method: str = Form("POST"),
|
||||
extra_headers: str = Form(""),
|
||||
) -> Response:
|
||||
connection = _connection(db, connection_id)
|
||||
connection.name = name.strip()[:120] or connection.name
|
||||
@@ -157,6 +178,13 @@ async def update_connection(
|
||||
method = unload_method.strip().upper()
|
||||
connection.unload_method = method if method in ("GET", "POST") else "POST"
|
||||
|
||||
# `extra_headers_json` has been sent with every request to this endpoint
|
||||
# since it was added and written by no form in the application, so its one
|
||||
# documented use -- OpenRouter wants an `HTTP-Referer` and an `X-Title` --
|
||||
# was unreachable. One `Name: value` per line, because a JSON textarea asks
|
||||
# somebody to get braces right in a settings screen.
|
||||
connection.extra_headers_json = _parse_headers(extra_headers)
|
||||
|
||||
submitted = api_key.strip()
|
||||
if submitted and submitted != UNCHANGED_SENTINEL:
|
||||
connection.api_key_encrypted = encrypt(submitted)
|
||||
|
||||
@@ -1987,6 +1987,21 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
folder = db.get(Folder, wanted) if wanted else None
|
||||
chat.folder_id = folder.id if folder is not None and folder.user_id == user.id else None
|
||||
|
||||
# Out of the way, and reversible.
|
||||
#
|
||||
# `Chat.archived` has been filtered on in four places since folders arrived
|
||||
# and written by nothing anywhere -- so the hiding worked, the archiving
|
||||
# did not, and the column read as a built feature to anyone who grepped for
|
||||
# it. Here rather than as its own endpoint because it is a property of the
|
||||
# chat, exactly like its title and its folder, and `update_chat` already
|
||||
# reads the raw form for the reason this field needs too: absent must mean
|
||||
# "leave it alone" and "0" must mean "put it back".
|
||||
archived_changed = False
|
||||
if "archived" in form:
|
||||
wanted = str(form["archived"]).strip() not in ("", "0", "false")
|
||||
archived_changed = wanted != chat.archived
|
||||
chat.archived = wanted
|
||||
|
||||
# The mode is the one agent field that changes mid-chat: it decides what
|
||||
# gets asked about, not what the conversation is.
|
||||
if "agent_mode" in form:
|
||||
@@ -2100,6 +2115,24 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
return HTMLResponse(
|
||||
templates.get_template("chat/_title_oob.html").render({"chat": chat})
|
||||
)
|
||||
|
||||
# Archiving moves a row out of one group and into another, so the sidebar
|
||||
# has to be re-rendered -- and it cannot be, from a 204. htmx's own config
|
||||
# is `{code: "204", swap: false}`, so a control aimed at `#sidebar-tree`
|
||||
# with this endpoint's usual answer sets the column and then does visibly
|
||||
# nothing at all, which is this codebase's signature failure rather than a
|
||||
# new one. The same fragment and the same `oob` the sidebar switch returns,
|
||||
# for the same reason: New chat lives above the tree and comes along out of
|
||||
# band.
|
||||
if archived_changed:
|
||||
from lembas.api.pages import sidebar_context
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/_sidebar_tree.html",
|
||||
{"chat": None, "user": user, "oob": True, **sidebar_context(db, user)},
|
||||
)
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
||||
+79
-3
@@ -10,6 +10,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.config import settings
|
||||
from lembas.db.models import (
|
||||
KIND_CHAT,
|
||||
KIND_MESSAGES,
|
||||
@@ -42,6 +43,17 @@ router = APIRouter(tags=["pages"])
|
||||
THEME_COLOUR = {"moria": "#101317", "shire": "#F6F1E4"}
|
||||
|
||||
|
||||
def _instance_colour(brand) -> str:
|
||||
"""The background this instance paints before anything has loaded.
|
||||
|
||||
A custom theme sets `bg` itself; otherwise the built-in it inherits from
|
||||
decides, which is what `data-base` means everywhere else. Falls back to
|
||||
Moria rather than raising -- a splash screen is not worth a 500.
|
||||
"""
|
||||
theme = brand.theme(settings.default_theme)
|
||||
return theme.tokens.get("bg") or THEME_COLOUR.get(theme.base, THEME_COLOUR["moria"])
|
||||
|
||||
|
||||
def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
"""Model lists and permissions every chat page needs.
|
||||
|
||||
@@ -388,9 +400,29 @@ def sidebar_context(db: DBSession, user: User) -> dict:
|
||||
unfiled = list(
|
||||
db.scalars(narrowed.order_by(Chat.pinned.desc(), Chat.updated_at.desc()))
|
||||
)
|
||||
# The same query with the one filter inverted, and no `pinned` in the order:
|
||||
# a pinned chat that somebody archived is one they have said two opposite
|
||||
# things about, and the more recent instruction is the one to honour.
|
||||
archived = list(
|
||||
db.scalars(
|
||||
select(Chat)
|
||||
.where(
|
||||
Chat.user_id == user.id,
|
||||
Chat.archived.is_(True),
|
||||
Chat.temporary.is_(False),
|
||||
Chat.kind.in_((kind,) if kind else KINDS),
|
||||
)
|
||||
.order_by(Chat.updated_at.desc())
|
||||
)
|
||||
)
|
||||
return {
|
||||
"folders": folders,
|
||||
"unfiled_chats": unfiled,
|
||||
# Archived chats are NOT narrowed to unfiled ones: a chat inside a
|
||||
# folder disappears from that folder when it is archived (the folder's
|
||||
# own listing has always filtered them out), so without this it would
|
||||
# have left one list and joined none.
|
||||
"archived_chats": archived,
|
||||
# The shortcuts at the top of the sidebar. Here rather than in
|
||||
# `_chat_context`, where they used to be, for two reasons: they are
|
||||
# sidebar content and the fragment route that re-renders the sidebar has
|
||||
@@ -472,17 +504,61 @@ async def manifest(db: Db) -> Response:
|
||||
"""
|
||||
brand = branding_service.for_db(db)
|
||||
icons = brand.icon_paths
|
||||
colour = _instance_colour(brand)
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": "/",
|
||||
# Matches `start_url`. An id is only an identity key and need not be
|
||||
# navigable, but "/" named a path that serves nothing but a redirect
|
||||
# while the app started somewhere else, which reads as a mistake to
|
||||
# anyone comparing the two.
|
||||
"id": "/chat",
|
||||
"name": brand.name,
|
||||
"short_name": brand.name[:12],
|
||||
"description": brand.tagline or "A web UI for your language models.",
|
||||
"lang": "en",
|
||||
"dir": "ltr",
|
||||
"start_url": "/chat",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": THEME_COLOUR["moria"],
|
||||
"theme_color": THEME_COLOUR["moria"],
|
||||
# Ordered best-first: a browser takes the first it understands and
|
||||
# falls through to `display` if it understands none of them.
|
||||
"display_override": ["standalone", "minimal-ui"],
|
||||
"orientation": "any",
|
||||
"categories": ["productivity", "utilities"],
|
||||
# Opening a link belonging to this scope focuses the window that is
|
||||
# already open rather than making a second one.
|
||||
"launch_handler": {"client_mode": "navigate-existing"},
|
||||
# The launcher's long-press menu. Three destinations rather than
|
||||
# ten: a menu nobody can read at a glance is a menu nobody opens.
|
||||
"shortcuts": [
|
||||
{"name": "New chat", "url": "/chat"},
|
||||
{"name": "Messages", "url": "/messages"},
|
||||
{"name": "Scheduled", "url": "/scheduled"},
|
||||
],
|
||||
# Both follow whatever theme this instance is set up in. They were
|
||||
# Moria's near-black regardless, so a parchment instance installed
|
||||
# to a phone flashed a dark splash screen and then opened light --
|
||||
# and `THEME_COLOUR["shire"]` sat beside them, defined and read by
|
||||
# nothing. The *instance* default and not the reader's own theme:
|
||||
# a manifest is fetched without credentials unless the link asks
|
||||
# otherwise, so there is nobody to ask.
|
||||
"background_color": colour,
|
||||
"theme_color": colour,
|
||||
# Without these, Chrome on Android offers the one-line mini-infobar
|
||||
# rather than the install dialog that carries a name, an icon and a
|
||||
# picture -- which is the difference between an install somebody
|
||||
# chooses and one they swipe away without reading. Captured from the
|
||||
# running application by `scripts/shoot.py --manifest-screenshots`,
|
||||
# because the one thing a screenshot must not be is a drawing of
|
||||
# what the application looks like.
|
||||
"screenshots": [
|
||||
{"src": "/static/img/screenshot-narrow.png", "sizes": "390x844",
|
||||
"type": "image/png", "form_factor": "narrow",
|
||||
"label": "A conversation on a phone"},
|
||||
{"src": "/static/img/screenshot-wide.png", "sizes": "1280x800",
|
||||
"type": "image/png", "form_factor": "wide",
|
||||
"label": "A conversation, with the sidebar beside it"},
|
||||
],
|
||||
# An uploaded logo's derived icons, or the shipped ones. Whole-set
|
||||
# rather than per size: a manifest listing two custom icons and one
|
||||
# shipped is a launcher tile that changes when the device picks a
|
||||
|
||||
Reference in New Issue
Block a user