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
+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 %}