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:
+70
-12
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user