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:
@@ -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.
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user