A folder that carries something, and a way to name one

A folder was a name and nothing else -- and not even that, since PATCH could
rename one and nothing in the interface ever called it. It now carries a
description, a system prompt, and seeds for the model, the kind and the agent
target, with a settings page behind the row.

The prompt is a fourth rung on the ladder, chat > folder > model > instance,
and it goes above the model deliberately: a model's prompt describes the model
wherever it is used, a folder's describes this piece of work whichever model
is pointed at it. It is read when a reply is built rather than copied when a
chat is made, so editing it reaches the chats already there, and the walk up
the parents is bounded and cycle-safe because it runs on the request path.
`api/pages.py` mirrors the ladder for the settings panel and had to gain the
same rung -- a panel naming the wrong source is worse than one naming none,
because it is believed.

The seeds fill in what the request left empty and nothing it filled in: the
folder says what this work usually needs, the screen in front of somebody says
what they want this time. `ssh_profile_id` is a plain string rather than a
foreign key, for the reason `compacted_through_id` is, so it is validated on
read.

Getting *into* a folder needed fixing too. `/api/chats/start` has accepted a
folder_id since folders existed and nothing ever sent one, so the only route in
was to make the chat elsewhere and move it. There is a New chat here on the row
now, and `?folder=` on the new-chat screen.

Naming is a themed dialog, and deliberately not htmx's hx-prompt: htmx calls
the browser's prompt() synchronously and only then fires htmx:prompt with the
answer already in hand, so intercepting the event cannot supply a different one
and the grey box appears anyway. `data-prompt` follows the data-confirm-button
shape instead -- swallow the click, ask, write the answer into hx-vals,
click again behind a guard. JSON.stringify rather than concatenation, or a
folder called `"` produces hx-vals that does not parse and the rename silently
does nothing. Driven under a DOM stub, and there is a test that no template
brings hx-prompt back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-04 08:30:49 +02:00
parent 9c61e40662
commit b602657450
12 changed files with 821 additions and 16 deletions
+19
View File
@@ -21,6 +21,7 @@ from lembas.db.models import (
ROLE_ASSISTANT,
ROLE_USER,
Chat,
Folder,
Message,
Model,
User,
@@ -95,7 +96,25 @@ def _new_chat(
offered the control -- by which point the model had already answered under
the wrong rules. The reasoning effort is accepted for the same reason, and
wins over the model's default: an explicit choice beats an inherited one.
A folder's own defaults fill in anything the request left empty, and nothing
it filled in. That order is the point: the folder says what this piece of
work usually needs, and the screen in front of somebody says what they want
this time. The folder's system prompt is deliberately not among them -- it
is read at request time so that editing the folder later reaches the chats
already in it.
"""
folder = db.get(Folder, folder_id) if folder_id else None
if folder is not None and folder.user_id != user.id:
folder = None
if folder is not None:
model_id = model_id or folder.model_id
kind = kind or folder.kind
if kind == KIND_AGENT:
ssh_profile_id = ssh_profile_id or folder.ssh_profile_id
project_dir = project_dir or folder.project_dir
agent_mode = agent_mode or folder.agent_mode
chosen = None
if model_id:
match = next(
+70 -12
View File
@@ -2,11 +2,12 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Form, HTTPException, Response, status
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.db.models import Folder
from lembas.db.models import KINDS, Folder
from lembas.services.agent import policy as agent_policy
# Every route here manages folders, so the guard belongs on the router.
router = APIRouter(
@@ -47,13 +48,26 @@ def _refresh_sidebar() -> Response:
return response
def _prompted(request: Request) -> str:
"""What somebody typed into an `hx-prompt` dialog, if anything.
htmx sends it as a header rather than a field, because the element carrying
the attribute may not be a form control at all. `ui.js` swaps the browser's
own prompt for the themed one and hands the answer back through the same
header, so this reads identically either way.
"""
return (request.headers.get("HX-Prompt") or "").strip()
@router.post("")
async def create_folder(
request: Request,
db: Db,
user: RequiredUser,
name: str = Form("New folder"),
name: str = Form(""),
parent_id: str = Form(""),
) -> Response:
name = name.strip() or _prompted(request)
parent = _owned_folder(db, parent_id, user.id) if parent_id else None
# A cap on nesting, so a runaway client cannot build a tree deep enough to
@@ -67,7 +81,7 @@ async def create_folder(
db.add(
Folder(
user_id=user.id,
name=name.strip()[:200] or "New folder",
name=name[:200] or "New folder",
parent_id=parent.id if parent else None,
)
)
@@ -75,21 +89,47 @@ async def create_folder(
return _refresh_sidebar()
# The settings a folder hands to chats started inside it, and how far each may
# run. A table rather than a run of `if` blocks so the save handler and the form
# cannot come to disagree about which fields exist -- the same reasoning the
# tool label table carries.
_SEEDS = {
"description": 500,
"system_prompt": 20_000,
"model_id": 300,
"ssh_profile_id": 32,
"project_dir": 1000,
}
@router.patch("/{folder_id}")
async def update_folder(
request: Request,
db: Db,
user: RequiredUser,
folder_id: str,
name: str | None = Form(None),
parent_id: str | None = Form(None),
collapsed: bool | None = Form(None),
) -> Response:
"""Rename, move, collapse, or set what this folder hands to its chats.
Reads the raw form rather than declaring `Form(None)` parameters, because
FastAPI cannot tell an empty field from an absent one -- a submitted `x=`
arrives as None, so "clear this prompt" and "leave it alone" would be the
same request. Key presence is the distinction, which is the rule
`api/chats.py:update_chat` already follows and the reason every field here
is clearable.
"""
folder = _owned_folder(db, folder_id, user.id)
form = await request.form()
if name is not None and name.strip():
folder.name = name.strip()[:200]
# A rename can arrive from a settings form or from an `hx-prompt` button on
# the folder row; one route serves both. A blank name is ignored rather than
# stored, since a folder nobody can see the name of is one nobody can find.
name = str(form.get("name") or "").strip() or _prompted(request)
if name:
folder.name = name[:200]
if parent_id is not None:
if "parent_id" in form:
parent_id = str(form["parent_id"]).strip()
new_parent = _owned_folder(db, parent_id, user.id) if parent_id else None
# Reparenting a folder into its own subtree would detach that subtree
# from the root and make it unreachable.
@@ -103,10 +143,28 @@ async def update_folder(
cursor = db.get(Folder, cursor.parent_id) if cursor.parent_id else None
folder.parent_id = new_parent.id if new_parent else None
if collapsed is not None:
folder.collapsed = collapsed
if "collapsed" in form:
folder.collapsed = str(form["collapsed"]).lower() in ("1", "true", "on", "yes")
for field, limit in _SEEDS.items():
if field in form:
setattr(folder, field, str(form[field]).strip()[:limit])
# Both are vocabularies rather than free text, and both accept "" for "no
# opinion". Anything else is dropped rather than stored: a folder seeding a
# kind that is not a kind would hand every chat started in it a value that
# `_new_chat` then has to ignore anyway.
if "kind" in form:
wanted = str(form["kind"]).strip()
folder.kind = wanted if wanted in KINDS else ""
if "agent_mode" in form:
wanted = str(form["agent_mode"]).strip()
folder.agent_mode = wanted if wanted in agent_policy.MODES else ""
db.commit()
# One rule for every caller: reload. A rename or a move changes the tree,
# and a save from the settings page comes back showing what was stored --
# which is what somebody who pressed Save wants to see anyway.
return _refresh_sidebar()
+63 -1
View File
@@ -354,6 +354,7 @@ async def chat_index(
model: str = "",
temporary: bool = False,
kind: str = "",
folder: str = "",
):
"""A composer with no chat behind it yet.
@@ -364,14 +365,35 @@ async def chat_index(
is how the sidebar's Agent side opens a new chat already on that side --
a preselection like the other two, not a decision: the kind is still
chosen on the screen and still fixed only when the first message is sent.
`?folder=` is the same again, and is what "New chat here" on a folder row
posts: the chat is filed there, and `_new_chat` fills in whatever the
folder seeds and the screen left empty.
"""
context = _chat_context(db, user, None)
# Somebody else's folder id in the URL is ignored rather than refused. It
# would only ever get there by hand, and an error page holding a composer
# hostage over a bad query string helps nobody.
starting_folder = db.get(Folder, folder) if folder else None
if starting_folder is not None and starting_folder.user_id != user.id:
starting_folder = None
# A folder that fixes the kind picks the fork, unless the URL already said.
if not kind and starting_folder is not None:
kind = starting_folder.kind
# 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)
# The folder's own model, ahead of the reader's default and behind an
# explicit `?model=`. Same order `_new_chat` applies, so the picker shows
# the model the chat is actually going to be created with -- which matters,
# because the composer decides from it whether to warn about images.
if preselected is None and starting_folder is not None and starting_folder.model_id:
preselected = next(
(m for m in context["models"] if m.model_id == starting_folder.model_id), None
)
if preselected is None:
chosen = chat_service.default_model(db, user)
if chosen is not None:
@@ -392,12 +414,45 @@ async def chat_index(
"current_model": preselected,
"starting_temporary": temporary,
"starting_kind": kind if kind in KINDS else KIND_CHAT,
"starting_folder": starting_folder,
"suggestions": suggestions_service.visible(db),
**sidebar_context(db, user),
},
)
@router.get("/folders/{folder_id}")
async def folder_settings(request: Request, db: Db, user: RequiredUser, folder_id: str):
"""What a folder hands to the chats started inside it.
A page rather than a row that expands, following the admin convention: a
form per row in a tree that nests eight deep would be unusable, and the
sidebar is the one part of the application that has to stay scannable.
Guarded by `folder.manage`, the same permission the whole folder router
carries -- editing a folder's system prompt is managing a folder, and a page
that renders for somebody whose save is going to 403 is a trap.
"""
if not permissions.has(db, user, "folder.manage"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "You cannot manage folders.")
folder = db.get(Folder, folder_id)
if folder is None or folder.user_id != user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That folder no longer exists.")
return render(
request,
"folders/edit.html",
{
"folder": folder,
"chat": None,
"models": chat_service.available_models(db, user),
**_agent_context(db, user, None),
**sidebar_context(db, user),
},
)
@router.get("/chat/{chat_id}")
async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str):
chat = db.get(Chat, chat_id)
@@ -431,11 +486,18 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
# What the chat would use if its own prompt were empty, so the settings
# panel can show it as placeholder text rather than leaving the user to
# guess what "inherited" means.
#
# This mirrors `chat_service.effective_system_prompt` and has to keep
# mirroring it, layer for layer and in the same order -- a panel naming the
# wrong source is worse than one naming none, because it is believed.
inherited, inherited_from = "", ""
folder_prompt = chat_service.folder_system_prompt(db, chat)
current = next(
(m for m in chat_service.available_models(db, user) if m.model_id == chat.model_id), None
)
if current is not None and (current.system_prompt or "").strip():
if folder_prompt:
inherited, inherited_from = folder_prompt, "folder"
elif current is not None and (current.system_prompt or "").strip():
inherited, inherited_from = current.system_prompt.strip(), "model"
else:
instance_prompt = (settings_store.get(db, "system_prompt") or "").strip()
+22
View File
@@ -49,6 +49,28 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
collapsed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# What chats started in this folder inherit. A folder is where somebody
# groups the work on one thing, so it is the natural place to say "chats
# about this use this prompt, this model, this machine" -- said once rather
# than on every new chat.
description: Mapped[str] = mapped_column(String(500), default="")
# Read at request time, never copied onto the chat: editing the folder later
# has to reach the chats already in it, which is the whole point of putting
# it here. It slots into the ladder between the chat and the model.
system_prompt: Mapped[str] = mapped_column(Text, default="")
# Seeds, copied onto a new chat and then that chat's own. Empty means "no
# opinion", so a folder can carry a prompt without also dictating a model.
model_id: Mapped[str] = mapped_column(String(300), default="")
kind: Mapped[str] = mapped_column(String(16), default="")
# Deliberately not a ForeignKey. `migrations.py` compiles the column type
# only, so a REFERENCES clause would exist on a fresh database and not on an
# upgraded one -- the same reason `Chat.compacted_through_id` is a plain id.
# The profile may also have been deleted, so it is validated on read.
ssh_profile_id: Mapped[str] = mapped_column(String(32), default="")
project_dir: Mapped[str] = mapped_column(String(1000), default="")
agent_mode: Mapped[str] = mapped_column(String(16), default="")
children: Mapped[list[Folder]] = relationship(
back_populates="parent",
cascade="all, delete-orphan",
+32 -2
View File
@@ -149,23 +149,53 @@ def message_payload(message: Message, *, vision: bool) -> dict[str, Any]:
return {"role": message.role, "content": parts}
def folder_system_prompt(db: DBSession, chat: Chat) -> str:
"""The nearest prompt on the chat's folder, or on a folder above it.
Walks up rather than reading one level, because folders nest and a project's
prompt belongs on the project rather than on each sub-folder of it. The
nearest one wins, which is the same rule the ladder as a whole follows.
Bounded and cycle-safe the way `api/folders.py:_depth_of` is. Reparenting
already refuses to build a cycle, but this runs on the request path for
every reply and a row written by something else must not be able to hang it.
"""
from lembas.db.models import Folder
folder = chat.folder
seen: set[str] = set()
while folder is not None and folder.id not in seen:
seen.add(folder.id)
if (folder.system_prompt or "").strip():
return folder.system_prompt.strip()
folder = db.get(Folder, folder.parent_id) if folder.parent_id else None
return ""
def effective_system_prompt(db: DBSession, chat: Chat) -> str:
"""The system prompt a chat actually runs with.
Three layers, most specific wins outright:
Four layers, most specific wins outright:
chat > model > instance
chat > folder > model > instance
Precedence rather than concatenation. Stacking them reads well in a
settings screen and badly in practice: the moment two layers disagree the
model gets contradictory instructions and nobody can tell which one is
losing. With precedence, "why is it behaving like this" has one answer.
The folder sits above the model because it is the more specific statement:
a model's prompt describes the model wherever it is used, and a folder's
describes this piece of work whichever model is pointed at it.
"""
from lembas.services import settings_store
if chat.system_prompt.strip():
return chat.system_prompt.strip()
if inherited := folder_system_prompt(db, chat):
return inherited
model = db.scalar(
select(Model).where(Model.model_id == chat.model_id).order_by(Model.position)
)
+43
View File
@@ -205,6 +205,49 @@
});
}, true);
/* Asking for one line of text before a request goes out: renaming a folder,
renaming a chat.
Deliberately NOT htmx's own hx-prompt. htmx calls the browser's prompt()
synchronously and only then fires htmx:prompt with the answer already in
hand -- so intercepting the event cannot supply a different one, and the
native box appears regardless. Cancelling the event only aborts the
request. This is the data-confirm-button shape instead: swallow the click,
ask in our own dialog, write the answer where htmx will collect it, and
click again behind a guard flag.
The answer goes into hx-vals as a normal field rather than into a header,
because every route that wants it already reads a form. htmx reads
attributes when the request is built, so setting it just before the second
click is enough. It is JSON.stringify'd, never concatenated: a folder
called `"` would otherwise produce hx-vals that does not parse, and the
request would go out with the field missing rather than with the name. */
document.addEventListener("click", function (event) {
var el = event.target.closest("[data-prompt]");
if (!el || el.dataset.prompted) return;
event.preventDefault();
event.stopPropagation();
var field = el.dataset.promptField || "name";
prompt({
title: el.dataset.promptTitle,
message: el.dataset.prompt,
value: el.dataset.promptValue || "",
confirmLabel: el.dataset.promptLabel || "Save",
}).then(function (value) {
/* null is Cancel. An empty string is somebody clearing the box and
pressing Save, which is not a rename either -- the routes ignore a
blank name, so sending it would be a request that does nothing. */
if (value === null || !String(value).trim()) return;
var values = {};
values[field] = String(value).trim();
el.setAttribute("hx-vals", JSON.stringify(values));
el.dataset.prompted = "1";
el.click();
delete el.dataset.prompted;
});
}, true);
/* Plain forms opt in with data-confirm, so they need no inline onsubmit. */
document.addEventListener("submit", function (event) {
var form = event.target;
@@ -66,6 +66,12 @@
{% if not chat and starting_temporary %}
<input type="hidden" name="temporary" value="true">
{% endif %}
{% if not chat and starting_folder %}
{# `/api/chats/start` has accepted a folder_id since folders existed and
nothing ever sent one, so the only way into a folder was to make the
chat elsewhere and move it. This is "New chat here" arriving. #}
<input type="hidden" name="folder_id" value="{{ starting_folder.id }}">
{% endif %}
{#
The text, with a mirror behind it.
+163
View File
@@ -0,0 +1,163 @@
{% extends "base.html" %}
{% from "_macros.html" import icon %}
{#
What a folder hands to the chats started inside it.
A page rather than a panel in the sidebar, following the admin convention:
compact rows, and the full form one click away. A form per folder row in a
tree that nests eight deep would be unusable, and the sidebar is the one
place in the application that has to stay scannable.
The whole form is one PATCH at the route that already existed. Every field is
clearable, because `update_folder` reads the raw form and checks key presence
rather than declaring `Form(None)` parameters -- with those, an empty box and
an absent one are the same request.
#}
{% block title %}{{ folder.name }} - LLeMbas{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
{% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %}
{% block body %}
<div class="shell">
{% include "partials/sidebar.html" %}
<main class="main">
<header class="topbar">
<h1 class="topbar__title">
{{ icon("folder", "icon--sm") }}
<span>{{ folder.name }}</span>
</h1>
<span class="spacer"></span>
<a class="btn btn--sm" href="/chat?folder={{ folder.id }}">
{{ icon("plus", "icon--sm") }} New chat here
</a>
</header>
<div class="page">
<form hx-patch="/api/folders/{{ folder.id }}" hx-swap="none">
<div class="card">
<h2 class="card__title">Name</h2>
<div class="field">
<input class="input" type="text" name="name" maxlength="200"
value="{{ folder.name }}" aria-label="Folder name" required>
</div>
<div class="field">
<label class="field__label" for="folder-description">Description</label>
<input class="input" type="text" id="folder-description" name="description"
maxlength="500" value="{{ folder.description }}"
placeholder="What this folder is for.">
<p class="field__hint">
For you, not for any model. It is never sent anywhere.
</p>
</div>
</div>
<div class="card">
<h2 class="card__title">System prompt</h2>
<p class="card__lede">
Used by every chat in this folder, and by folders nested inside it,
unless the chat has a prompt of its own. Read each time a reply is
built rather than copied when a chat is made, so editing this
reaches the chats already here.
</p>
<div class="field">
<textarea class="textarea" name="system_prompt" rows="8"
aria-label="System prompt"
placeholder="Leave empty to fall through to the model's prompt, then the instance's.">{{ folder.system_prompt }}</textarea>
<p class="field__hint">
Precedence, not concatenation: chat, then folder, then model, then
instance. The most specific one wins outright.
</p>
</div>
</div>
<div class="card">
<h2 class="card__title">What a new chat starts as</h2>
<p class="card__lede">
Seeds, copied onto a chat when it is created and its own from then
on. Anything chosen on the new-chat screen wins over these.
</p>
<div class="field">
<label class="field__label" for="folder-model">Model</label>
<select class="select" id="folder-model" name="model_id">
<option value="">No opinion</option>
{% for model in models %}
<option value="{{ model.model_id }}"
{{ 'selected' if model.model_id == folder.model_id }}>
{{ model.label }}
</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="field__label" for="folder-kind">Kind</label>
<select class="select" id="folder-kind" name="kind">
<option value="" {{ 'selected' if not folder.kind }}>No opinion</option>
<option value="chat" {{ 'selected' if folder.kind == 'chat' }}>Chat</option>
<option value="agent" {{ 'selected' if folder.kind == 'agent' }}>Agent chat</option>
</select>
<p class="field__hint">
A folder set to one kind shows on only that side of the sidebar's
switch, and opens the new-chat screen already on that fork.
</p>
</div>
{% if agent_profiles %}
{# Only meaningful for an agent chat, and shown regardless of the kind
above: somebody filling this in is on their way to setting the kind
too, and a field that appears only once another field is right is a
field people conclude is missing. #}
<div class="field">
<label class="field__label" for="folder-profile">Connection</label>
<select class="select" id="folder-profile" name="ssh_profile_id">
<option value="">No opinion</option>
{% for profile in agent_profiles %}
<option value="{{ profile.id }}"
{{ 'selected' if profile.id == folder.ssh_profile_id }}>
{{ profile.name }}
</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="field__label" for="folder-dir">Project directory</label>
<input class="input input--mono" type="text" id="folder-dir" name="project_dir"
maxlength="1000" value="{{ folder.project_dir }}"
placeholder="The connection's own default.">
</div>
{% if agent_modes %}
<div class="field">
<label class="field__label" for="folder-mode">Approval mode</label>
<select class="select" id="folder-mode" name="agent_mode">
<option value="">No opinion</option>
{% for value, label, hint in agent_modes %}
<option value="{{ value }}" title="{{ hint }}"
{{ 'selected' if value == folder.agent_mode }}>{{ label }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% endif %}
</div>
<div class="form-actions">
<button class="btn btn--primary" type="submit">
{{ icon("check", "icon--sm") }} Save
</button>
<a class="btn" href="/chat">Back</a>
</div>
</form>
</div>
</main>
</div>
{% endblock %}
@@ -18,6 +18,25 @@
<span class="nav-item__label">{{ folder.name }}</span>
</button>
<span class="nav-item__actions">
{# Start a chat already filed here, and already carrying whatever the
folder seeds. Without this the only way into a folder is to make the
chat somewhere else and move it. #}
<a class="btn btn--icon btn--sm" href="/chat?folder={{ folder.id }}"
aria-label="New chat in this folder" title="New chat here">
{{ icon("plus", "icon--sm") }}
</a>
<button class="btn btn--icon btn--sm" type="button"
hx-patch="/api/folders/{{ folder.id }}" hx-swap="none"
data-prompt="What should this folder be called?"
data-prompt-title="Rename folder" data-prompt-field="name"
data-prompt-value="{{ folder.name }}"
aria-label="Rename folder" title="Rename folder">
{{ icon("pencil", "icon--sm") }}
</button>
<a class="btn btn--icon btn--sm" href="/folders/{{ folder.id }}"
aria-label="Folder settings" title="Folder settings">
{{ icon("sliders", "icon--sm") }}
</a>
<button class="btn btn--icon btn--sm" type="button"
hx-delete="/api/folders/{{ folder.id }}"
hx-confirm="Delete the folder “{{ folder.name }}”? Chats inside it are kept."
@@ -23,8 +23,14 @@
</a>
{% endif %}
{% if can.get("folder.manage") %}
{# Asks for the name rather than making "New folder" and leaving somebody to
find the rename. `data-prompt` writes the answer into hx-vals before the
request goes out; see ui.js for why this is not htmx's own hx-prompt. #}
<button class="btn btn--icon" hx-post="/api/folders" hx-swap="none"
hx-vals='{"name": "New folder"}' aria-label="New folder" title="New folder">
data-prompt="What should this folder be called?"
data-prompt-title="New folder" data-prompt-field="name"
data-prompt-label="Create"
aria-label="New folder" title="New folder">
{{ icon("folder") }}
</button>
{% endif %}