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 d7a614c96b
commit ec12c3a981
12 changed files with 821 additions and 16 deletions
+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()