The composer decides what a chat is, and the topbar stops trying
The mode select in the topbar posted with hx-post against a route that only answers PATCH, so every change returned 405 and the mode never moved. htmx shows nothing when a request fails, so the control looked like it worked: the select stayed where you put it and the server ignored you. It has never worked. Two more of the same kind. A mode could not be chosen at all until the chat existed, so reaching Plan meant sending something in Manual first and letting the model answer under the wrong rules. And the project directory box was real and submitted, but unlabelled and squeezed to a few characters by the select beside it, so it read as broken -- which is how it was reported. So the kind, the connection, the directory and the mode move out of the strip above the text and into one toolbar row beneath it, where attach and send already are. The directory becomes a button that opens a browser over SFTP, because a path is something you would rather find than spell. `scan_dir` is new beside `list_dir`: a picker has to tell a directory from a file before it can draw the row, and `list_dir` backs a tool whose contract is a list of names and must not change under a model mid-conversation. Browsing is a person clicking, not a model calling, so it does not pass through policy.py -- the same argument the terminal panel rests on. It does mean Manual mode has a second exception now. Also: .chip was two components with one name, and the attachment card won, so the Chat/Agent pills silently wore its padding. --radius-md was used twice and declared nowhere, so both fell back to 0. .btn.is-active has been set by syncToggles since the terminal landed and styled by nothing. Enter-to-send ignored isComposing, so committing an IME candidate sent the message. The terminal had five colours of a sixteen-colour palette, with fallbacks from a palette that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "lembas"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -194,6 +194,69 @@ async def profile_page(
|
||||
return _detail(request, db, user, profile, is_new=False, saved=saved)
|
||||
|
||||
|
||||
@router.get("/api/agents/{profile_id}/browse")
|
||||
async def browse_profile(
|
||||
request: Request, db: Db, user: RequiredUser, profile_id: str, path: str = ""
|
||||
):
|
||||
"""One directory on the far side, as a fragment the picker swaps in.
|
||||
|
||||
Hung off the profile rather than the chat because the commonest caller is
|
||||
the *new*-chat composer, where there is no chat yet -- the directory is one
|
||||
of the things being chosen. Ownership of the profile is the whole
|
||||
authorisation, as everywhere else in this module.
|
||||
|
||||
This is a person clicking, not a model calling, so it does not go through
|
||||
`agent/policy.py`. That is the same argument the terminal panel rests on and
|
||||
it holds for the same reason -- somebody who owns the credential could list
|
||||
the directory with an ssh client -- but it does mean Manual mode's promise
|
||||
that everything is shown to you first now has a second exception. Both are
|
||||
written down in CLAUDE.md.
|
||||
"""
|
||||
profile = _profile(db, user, profile_id)
|
||||
entries: list = []
|
||||
error = ""
|
||||
|
||||
if hint := ssh_service.available():
|
||||
error = hint
|
||||
elif not profile.host_key:
|
||||
# connect_kwargs would raise the same thing, but a picker that opens on
|
||||
# a wall of prose about known_hosts is worse than one that says this.
|
||||
error = "This connection's host key has not been confirmed yet. Check it first."
|
||||
else:
|
||||
try:
|
||||
executor = ssh_service.SshExecutor(ssh_service.spec_from(profile), "")
|
||||
entries = await executor.scan_dir(path or profile.default_dir or "/")
|
||||
except ExecError as exc:
|
||||
error = exc.message
|
||||
|
||||
here = path or profile.default_dir or "/"
|
||||
return render(
|
||||
request,
|
||||
"agents/_browse.html",
|
||||
{
|
||||
"profile": profile,
|
||||
"here": here,
|
||||
"parent": _parent_of(here),
|
||||
"entries": entries,
|
||||
"error": error,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _parent_of(path: str) -> str:
|
||||
"""The directory above, or "" at the root.
|
||||
|
||||
Plain string work rather than pathlib: these are POSIX paths on somebody
|
||||
else's machine, and running them through a local Path would apply this
|
||||
host's rules to them.
|
||||
"""
|
||||
trimmed = (path or "/").rstrip("/")
|
||||
if not trimmed or trimmed == "":
|
||||
return ""
|
||||
head = trimmed.rsplit("/", 1)[0]
|
||||
return head or "/"
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}/check")
|
||||
async def check_profile(request: Request, db: Db, user: RequiredUser, profile_id: str):
|
||||
"""Look at the host's key, and connect if it has already been accepted.
|
||||
|
||||
@@ -68,12 +68,19 @@ def _new_chat(
|
||||
kind: str = KIND_CHAT,
|
||||
ssh_profile_id: str = "",
|
||||
project_dir: str = "",
|
||||
agent_mode: str = "",
|
||||
) -> Chat:
|
||||
"""Create a chat row, resolving which model it should use.
|
||||
|
||||
An agent chat's connection is settled here and never again. That is the
|
||||
lock: the harness, the tools offered and the approval loop all differ, so a
|
||||
conversation whose earlier turns ran somewhere else is not one conversation.
|
||||
|
||||
The mode is *not* part of that lock and is accepted here so it can be chosen
|
||||
before the first word. Without it, reaching Plan mode meant starting a chat
|
||||
in Manual, sending something to make the chat exist, and only then being
|
||||
offered the control -- by which point the model had already answered under
|
||||
the wrong rules.
|
||||
"""
|
||||
chosen = None
|
||||
if model_id:
|
||||
@@ -96,6 +103,12 @@ def _new_chat(
|
||||
ssh_profile_id=profile.id if profile is not None else None,
|
||||
project_dir=(project_dir.strip() or profile.default_dir) if profile is not None else "",
|
||||
)
|
||||
# Ignored rather than refused when it is not a mode, matching how every
|
||||
# other bad value here collapses: somebody who mistypes should get a chat
|
||||
# under the safest rules, not an error page holding their message hostage.
|
||||
# Left alone entirely on a plain chat, where it means nothing.
|
||||
if profile is not None and agent_mode.strip() in agent_policy.MODES:
|
||||
chat.agent_mode = agent_mode.strip()
|
||||
db.add(chat)
|
||||
db.commit()
|
||||
return chat
|
||||
@@ -113,6 +126,7 @@ async def start_chat(
|
||||
kind: str = Form(KIND_CHAT),
|
||||
ssh_profile_id: str = Form(""),
|
||||
project_dir: str = Form(""),
|
||||
agent_mode: str = Form(""),
|
||||
) -> Response:
|
||||
"""Create a chat from its first message.
|
||||
|
||||
@@ -134,6 +148,7 @@ async def start_chat(
|
||||
kind=kind,
|
||||
ssh_profile_id=ssh_profile_id,
|
||||
project_dir=project_dir,
|
||||
agent_mode=agent_mode,
|
||||
)
|
||||
|
||||
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
|
||||
|
||||
@@ -86,6 +86,27 @@ class Target:
|
||||
spec: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemoteEntry:
|
||||
"""One line of a directory listing, with enough to draw it.
|
||||
|
||||
Separate from `list_dir`, which returns bare names and backs the
|
||||
`file_list` tool. That contract is a list of names and must not change
|
||||
under a model mid-conversation, so a picker -- which has to tell a
|
||||
directory from a file before it knows whether the row can be walked into
|
||||
-- gets its own method rather than a widened one.
|
||||
"""
|
||||
|
||||
name: str
|
||||
is_dir: bool
|
||||
size: int = 0
|
||||
modified: int = 0
|
||||
|
||||
@property
|
||||
def is_hidden(self) -> bool:
|
||||
return self.name.startswith(".")
|
||||
|
||||
|
||||
class Executor(Protocol):
|
||||
"""How a target is acted on. See `ssh.py`; there is no local variant."""
|
||||
|
||||
@@ -97,6 +118,8 @@ class Executor(Protocol):
|
||||
|
||||
async def list_dir(self, path: str) -> list[str]: ...
|
||||
|
||||
async def scan_dir(self, path: str) -> list[RemoteEntry]: ...
|
||||
|
||||
|
||||
def clean_output(data: bytes | str, *, limit: int) -> tuple[str, bool]:
|
||||
"""Decode, strip escape sequences, and cap. Returns (text, truncated)."""
|
||||
@@ -114,6 +137,7 @@ __all__ = [
|
||||
"ExecRequest",
|
||||
"ExecResult",
|
||||
"Executor",
|
||||
"RemoteEntry",
|
||||
"Target",
|
||||
"clean_output",
|
||||
]
|
||||
|
||||
@@ -35,6 +35,7 @@ from lembas.services.agent.base import (
|
||||
ExecError,
|
||||
ExecRequest,
|
||||
ExecResult,
|
||||
RemoteEntry,
|
||||
clean_output,
|
||||
)
|
||||
from lembas.services.crypto import decrypt
|
||||
@@ -298,6 +299,50 @@ class SshExecutor:
|
||||
visible = sorted(n for n in names if n not in (".", ".."))
|
||||
return visible[:MAX_ENTRIES]
|
||||
|
||||
async def scan_dir(self, path: str = "") -> list[RemoteEntry]:
|
||||
"""A listing with types, for a picker rather than for a model.
|
||||
|
||||
`readdir` rather than `listdir`: the latter returns bare names, and a
|
||||
browser has to know which rows can be walked into before it can draw
|
||||
them. Directories sort first and then by name, because that is the
|
||||
order somebody navigating expects -- `list_dir` keeps its plain
|
||||
lexicographic sort, since changing what a tool returns is changing a
|
||||
contract a model has already been shown.
|
||||
"""
|
||||
import stat
|
||||
|
||||
import asyncssh
|
||||
|
||||
try:
|
||||
async with self._connect() as conn, conn.start_sftp_client() as sftp:
|
||||
target = self._resolve(path) if path else (self.project_dir or ".")
|
||||
names = await sftp.readdir(target)
|
||||
except asyncssh.SFTPNoSuchFile as exc:
|
||||
raise ExecError(f"There is no directory at {path or self.project_dir}.") from exc
|
||||
except asyncssh.SFTPPermissionDenied as exc:
|
||||
raise ExecError(f"Not allowed to read {path or self.project_dir}.") from exc
|
||||
except (OSError, asyncssh.Error) as exc:
|
||||
raise self._wrap(exc) from exc
|
||||
|
||||
entries: list[RemoteEntry] = []
|
||||
for item in names:
|
||||
name = item.filename
|
||||
if name in (".", ".."):
|
||||
continue
|
||||
attrs = item.attrs
|
||||
permissions = getattr(attrs, "permissions", None) or 0
|
||||
entries.append(
|
||||
RemoteEntry(
|
||||
name=name,
|
||||
is_dir=stat.S_ISDIR(permissions),
|
||||
size=getattr(attrs, "size", None) or 0,
|
||||
modified=int(getattr(attrs, "mtime", None) or 0),
|
||||
)
|
||||
)
|
||||
|
||||
entries.sort(key=lambda entry: (not entry.is_dir, entry.name.lower()))
|
||||
return entries[:MAX_ENTRIES]
|
||||
|
||||
def _resolve(self, path: str) -> str:
|
||||
"""A path relative to the project directory, unless it is absolute.
|
||||
|
||||
|
||||
@@ -165,6 +165,26 @@ button, input, textarea, select {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/*
|
||||
A toggle whose panel is open.
|
||||
|
||||
`syncToggles` in app.js has been setting this class on every toggle pointing
|
||||
at a panel since the terminal landed, and nothing has ever styled it -- so an
|
||||
open inspector and a closed one gave their topbar buttons an identical
|
||||
appearance, and the only signal was the panel itself, which is off-screen at
|
||||
narrow widths. Written against `.btn` rather than `.btn--icon` so anything
|
||||
that becomes a toggle later is covered.
|
||||
*/
|
||||
.btn.is-active {
|
||||
background: var(--accent-soft);
|
||||
border-color: transparent;
|
||||
color: var(--accent);
|
||||
}
|
||||
.btn.is-active:hover:not(:disabled) {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn--sm { height: var(--control-h-sm); padding: 0 var(--control-px-sm); font-size: var(--text-xs); }
|
||||
.btn--sm.btn--icon { width: var(--control-h-sm); padding: 0; }
|
||||
.btn--lg { height: var(--control-h-lg); padding: 0 var(--sp-5); font-size: var(--text-base); }
|
||||
@@ -432,7 +452,13 @@ button, input, textarea, select {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.inspector__header {
|
||||
/*
|
||||
The header a side panel wears. The inspector and the terminal had this
|
||||
written out twice, identically, and they must stay identical: they sit side
|
||||
by side in the same slot and one being a pixel off reads as a rendering bug.
|
||||
Also the same height as .topbar, so the three line up across the shell.
|
||||
*/
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
@@ -441,11 +467,13 @@ button, input, textarea, select {
|
||||
padding: 0 var(--sp-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.inspector__title {
|
||||
.panel-head__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--ink-muted);
|
||||
@@ -505,7 +533,7 @@ button, input, textarea, select {
|
||||
.inspector {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
z-index: 40;
|
||||
z-index: var(--z-panel);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
}
|
||||
@@ -524,25 +552,6 @@ button, input, textarea, select {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.terminal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
height: var(--header-height);
|
||||
flex: none;
|
||||
padding: 0 var(--sp-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.terminal__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.terminal__where {
|
||||
font-weight: 400;
|
||||
font-family: var(--font-mono);
|
||||
@@ -584,7 +593,7 @@ button, input, textarea, select {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
width: min(var(--terminal-width), 100vw);
|
||||
z-index: 40;
|
||||
z-index: var(--z-panel);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
}
|
||||
@@ -793,7 +802,7 @@ button, input, textarea, select {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 40;
|
||||
z-index: var(--z-panel);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
/* Hiding it is the `hidden` attribute, forced to win at the top of this
|
||||
@@ -809,7 +818,7 @@ button, input, textarea, select {
|
||||
position: fixed;
|
||||
right: var(--sp-4);
|
||||
bottom: var(--sp-4);
|
||||
z-index: 60;
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
@@ -934,7 +943,7 @@ button, input, textarea, select {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--sp-1));
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
z-index: var(--z-dropdown);
|
||||
width: min(24rem, calc(100vw - var(--sp-8)));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -961,6 +970,27 @@ button, input, textarea, select {
|
||||
|
||||
/* A dialog that holds a searchable list rather than a question. */
|
||||
.dialog--wide { width: min(34rem, calc(100vw - var(--sp-6))); }
|
||||
.dialog__note { margin: 0; font-size: var(--text-sm); color: var(--ink-muted); }
|
||||
/* Where the directory browser currently stands. Sticky-feeling rather than
|
||||
sticky: it is above the list, so walking deeper never scrolls it away. */
|
||||
.dialog__where {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-sunken);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.dialog__where .mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* A row that is shown but cannot be chosen -- a file in a directory picker.
|
||||
Listed rather than hidden, because a directory of only files would otherwise
|
||||
look empty. */
|
||||
.picker__option.is-inert { cursor: default; color: var(--ink-faint); }
|
||||
.picker__option.is-inert:hover { background: none; }
|
||||
.dialog__results {
|
||||
max-height: min(24rem, 50vh);
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -669,43 +669,96 @@
|
||||
}
|
||||
.composer__inner { max-width: var(--thread-max-width); margin: 0 auto; }
|
||||
|
||||
/* A column: chips on top, then the control row. The chips are inside the form
|
||||
so their hidden file_ids inputs are submitted with the message. */
|
||||
/*
|
||||
A column: chips, then the text across the full width, then one toolbar row.
|
||||
|
||||
The text gets its own row rather than sharing one with the buttons, which is
|
||||
what lets the toolbar hold more than two controls without the input shrinking
|
||||
to nothing. The chips stay inside the form so their hidden file_ids inputs are
|
||||
submitted with the message.
|
||||
*/
|
||||
.composer__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
.composer__row {
|
||||
display: flex;
|
||||
gap: var(--sp-1);
|
||||
align-items: flex-end;
|
||||
}
|
||||
/* Attach and send are the same size and sit on the same baseline as the last
|
||||
line of the textarea, so the control row reads as one object. */
|
||||
.composer__btn { flex: none; align-self: flex-end; border-radius: var(--radius-full); }
|
||||
.composer__form:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
|
||||
.composer__input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
resize: none;
|
||||
padding: 0.5rem var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-2) var(--sp-1);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
max-height: 20rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
.composer__input:focus { outline: none; }
|
||||
|
||||
/* Everything that acts on the message, on one line under it. It wraps rather
|
||||
than scrolls: on a narrow window the context controls drop to their own row
|
||||
and attach/send stay where the thumb expects them. */
|
||||
.composer__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.composer__tools { display: flex; align-items: center; gap: var(--sp-1); flex: none; }
|
||||
.composer__actions { display: flex; align-items: center; gap: var(--sp-1); margin-left: auto; }
|
||||
.composer__context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.composer__agent { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
|
||||
|
||||
/* Round, and the same size as each other: attach and send read as one pair
|
||||
bracketing the row. */
|
||||
.composer__btn { flex: none; border-radius: var(--radius-full); }
|
||||
|
||||
/* The directory, on a new chat. Monospace because it is a path, and it grows
|
||||
to fit rather than being pinned to a width that truncates every real one. */
|
||||
.composer__dir { max-width: 16rem; font-family: var(--font-mono); font-weight: 400; }
|
||||
.composer__dir-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* The connection and directory of a chat already under way. Not a control:
|
||||
update_chat refuses to change either, so showing them as one is honest. */
|
||||
.composer__where {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
min-width: 0;
|
||||
max-width: 20rem;
|
||||
padding: 0 var(--sp-2);
|
||||
height: var(--control-h-sm);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-hover);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.composer__where-dir {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink-faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composer__hint {
|
||||
margin: var(--sp-2) 0 0;
|
||||
font-size: var(--text-xs);
|
||||
@@ -746,7 +799,12 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* --- Attachment chips (composer) ------------------------------------------ */
|
||||
/* --- Attachment chips (composer) ------------------------------------------
|
||||
Named `attach-chip` and not `chip`, which is the radio pill above. The two
|
||||
were both called `.chip` and both set padding, border and max-width, so the
|
||||
later block won and the Chat/Agent pills silently inherited the padding of
|
||||
an attachment card. Two components with one name is a collision, not a
|
||||
family. */
|
||||
.composer { position: relative; }
|
||||
|
||||
.composer__attachments {
|
||||
@@ -757,7 +815,7 @@
|
||||
}
|
||||
.composer__attachments:empty { display: none; }
|
||||
|
||||
.chip {
|
||||
.attach-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
@@ -768,10 +826,10 @@
|
||||
max-width: 20rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.chip--error { border-color: var(--danger); background: var(--danger-soft); }
|
||||
.chip--error .chip__icon { color: var(--danger); }
|
||||
.attach-chip--error { border-color: var(--danger); background: var(--danger-soft); }
|
||||
.attach-chip--error .attach-chip__icon { color: var(--danger); }
|
||||
|
||||
.chip__thumb {
|
||||
.attach-chip__thumb {
|
||||
display: block;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
@@ -779,17 +837,17 @@
|
||||
object-fit: cover;
|
||||
flex: none;
|
||||
}
|
||||
.chip__icon { color: var(--ink-muted); flex: none; display: flex; }
|
||||
.chip__body { min-width: 0; flex: 1; display: flex; flex-direction: column; }
|
||||
.chip__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chip__meta { font-size: var(--text-xs); color: var(--ink-faint); }
|
||||
.chip__warning { font-size: var(--text-xs); color: var(--danger); }
|
||||
.attach-chip__icon { color: var(--ink-muted); flex: none; display: flex; }
|
||||
.attach-chip__body { min-width: 0; flex: 1; display: flex; flex-direction: column; }
|
||||
.attach-chip__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.attach-chip__meta { font-size: var(--text-xs); color: var(--ink-faint); }
|
||||
.attach-chip__warning { font-size: var(--text-xs); color: var(--danger); }
|
||||
|
||||
/* --- Drag and drop -------------------------------------------------------- */
|
||||
.dropzone-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
z-index: var(--z-raised);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -894,33 +952,58 @@
|
||||
}
|
||||
.unread-dot[hidden] { display: none; }
|
||||
|
||||
/* --- Agent chats ----------------------------------------------------------- */
|
||||
/* In the header, not the settings panel: the mode is the difference between
|
||||
being interrupted and not, and it is looked at constantly. */
|
||||
.agent-bar { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.agent-bar__where {
|
||||
/* --- Agent chats -----------------------------------------------------------
|
||||
`.select--sm` and `.input--sm` are defined once, in app.css, at
|
||||
var(--control-h-sm). A second definition here made every small control on a
|
||||
chat page 0.15rem taller than the same control anywhere else -- which is
|
||||
exactly the drift --control-h exists to prevent.
|
||||
*/
|
||||
|
||||
/*
|
||||
Chat or Agent: one control with two halves, not two buttons that happen to be
|
||||
adjacent. Radios underneath, because the choice is permanent and mutually
|
||||
exclusive and should read as a fork -- and because a radio group is what a
|
||||
screen reader already knows how to announce.
|
||||
*/
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-sunken);
|
||||
}
|
||||
.segmented__option { position: relative; display: inline-flex; }
|
||||
.segmented__option input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
.segmented__option span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
color: var(--ink-muted);
|
||||
height: calc(var(--control-h-sm) - 2px);
|
||||
padding: 0 var(--sp-3);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
max-width: 14rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Chat or Agent, on the new-chat composer. */
|
||||
.composer__kind {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 0 var(--sp-2) var(--sp-2);
|
||||
.segmented__option input:hover + span { color: var(--ink-muted); }
|
||||
.segmented__option input:checked + span {
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.segmented__option input:focus-visible + span {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.composer__kind-agent { display: flex; gap: var(--sp-2); flex: 1 1 18rem; min-width: 0; }
|
||||
.composer__kind-agent .input { flex: 1; min-width: 0; }
|
||||
.select--sm, .input--sm { height: calc(var(--control-h) - 0.35rem); font-size: var(--text-xs); }
|
||||
|
||||
/* --- A plan, and the way to carry it out ----------------------------------- */
|
||||
.plan {
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
/* --- Radius & shadow -------------------------------------------------- */
|
||||
--radius-sm: 4px;
|
||||
--radius: 8px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 18px;
|
||||
--radius-full: 999px;
|
||||
@@ -70,11 +71,35 @@
|
||||
of --font-mono do not fit in 24rem, and a terminal narrower than eighty
|
||||
re-wraps everything a program prints. */
|
||||
--terminal-width: 34rem;
|
||||
--terminal-width-min: 24rem;
|
||||
--thread-max-width: 48rem;
|
||||
--header-height: 3.5rem;
|
||||
|
||||
/* The terminal's own type. xterm holds this as a number rather than reading
|
||||
it from CSS, so terminal.js parses it back out -- it must stay a plain
|
||||
pixel value. */
|
||||
--terminal-font-size: 13px;
|
||||
|
||||
/*
|
||||
--- Stacking ----------------------------------------------------------
|
||||
These were four bare numbers scattered across two stylesheets, which is
|
||||
fine until something new has to sit between two of them and nobody can
|
||||
say what is already there.
|
||||
*/
|
||||
--z-raised: 5;
|
||||
--z-handle: 10;
|
||||
--z-dropdown: 30;
|
||||
--z-panel: 40;
|
||||
--z-overlay: 50;
|
||||
--z-toast: 60;
|
||||
|
||||
--transition-fast: 120ms ease;
|
||||
--transition: 200ms ease;
|
||||
|
||||
/* The focus treatment, written once. Three components spelled it out. It
|
||||
resolves --accent-soft at the point of use, so it follows the theme even
|
||||
though it is declared above them. */
|
||||
--ring: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -144,6 +169,35 @@
|
||||
--shadow-lg: 0 12px 34px rgba(0, 0, 0, 0.55);
|
||||
|
||||
--scrim: rgba(6, 8, 10, 0.66);
|
||||
|
||||
/*
|
||||
The sixteen ANSI colours, for the terminal panel.
|
||||
|
||||
A shell picks its own colours -- `ls --color`, a git diff, htop's meters --
|
||||
and until these existed it got xterm's defaults, which are a different
|
||||
palette from this one and read as a foreign window pasted into the
|
||||
application. Where a slot has an obvious counterpart above it takes it, so
|
||||
an error in the terminal is the same ember as an error anywhere else.
|
||||
|
||||
"black" is not #000: it is what a program picks for a dim background or
|
||||
faint text, and on a near-black surface a true black is invisible.
|
||||
*/
|
||||
--ansi-black: #1A1F26;
|
||||
--ansi-red: #E2795A;
|
||||
--ansi-green: #9BCC5A;
|
||||
--ansi-yellow: #DFAE58;
|
||||
--ansi-blue: #8FB3CC;
|
||||
--ansi-magenta: #C08FCC;
|
||||
--ansi-cyan: #5FBFA0;
|
||||
--ansi-white: #C6CDD4;
|
||||
--ansi-bright-black: #4A535E;
|
||||
--ansi-bright-red: #EC8E72;
|
||||
--ansi-bright-green: #B1DD74;
|
||||
--ansi-bright-yellow: #EFC87A;
|
||||
--ansi-bright-blue: #A9C6DA;
|
||||
--ansi-bright-magenta: #D4A9DE;
|
||||
--ansi-bright-cyan: #7FD4BA;
|
||||
--ansi-bright-white: #E4E8EC;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -203,6 +257,30 @@
|
||||
--shadow-lg: 0 12px 34px rgba(72, 58, 34, 0.16);
|
||||
|
||||
--scrim: rgba(44, 36, 25, 0.4);
|
||||
|
||||
/*
|
||||
The same sixteen on parchment, and the reason they are not the Moria set
|
||||
lightened: every one of these has to be readable as *text* on #F2EBD9, so
|
||||
the whole palette is darkened rather than brightened. "bright" therefore
|
||||
means more emphatic here, not lighter -- a light terminal theme that made
|
||||
bright yellow actually bright would render it invisible.
|
||||
*/
|
||||
--ansi-black: #2C2419;
|
||||
--ansi-red: #A6432B;
|
||||
--ansi-green: #4C7A22;
|
||||
--ansi-yellow: #98701A;
|
||||
--ansi-blue: #3E6B7A;
|
||||
--ansi-magenta: #7A3E6B;
|
||||
--ansi-cyan: #2C7360;
|
||||
--ansi-white: #6A5C48;
|
||||
--ansi-bright-black: #94856D;
|
||||
--ansi-bright-red: #8C3722;
|
||||
--ansi-bright-green: #3C6318;
|
||||
--ansi-bright-yellow: #7A5A14;
|
||||
--ansi-bright-blue: #325867;
|
||||
--ansi-bright-magenta: #63325A;
|
||||
--ansi-bright-cyan: #235C4D;
|
||||
--ansi-bright-white: #453A2A;
|
||||
}
|
||||
|
||||
/* Respect a stated preference for reduced motion everywhere, at once. */
|
||||
|
||||
@@ -144,12 +144,14 @@
|
||||
.catch(function () {
|
||||
target.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
'<div class="chip chip--error"><span class="chip__body">' +
|
||||
'<span class="chip__name"></span>' +
|
||||
'<span class="chip__warning">Upload failed.</span></span></div>'
|
||||
'<div class="attach-chip attach-chip--error">' +
|
||||
'<span class="attach-chip__body">' +
|
||||
'<span class="attach-chip__name"></span>' +
|
||||
'<span class="attach-chip__warning">Upload failed.</span></span></div>'
|
||||
);
|
||||
// Set as text, never as HTML: the filename comes from the user.
|
||||
target.lastElementChild.querySelector(".chip__name").textContent = file.name;
|
||||
target.lastElementChild.querySelector(".attach-chip__name").textContent =
|
||||
file.name;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -262,6 +264,94 @@
|
||||
search.focus();
|
||||
}
|
||||
|
||||
/* --- Choosing a directory on the far side --------------------------------
|
||||
Shaped like attachKnowledge above, with one difference: a click walks
|
||||
deeper rather than finishing, and finishing is its own button. The path
|
||||
that gets submitted is the directory you are *standing in*, not the last
|
||||
row you pressed, so choosing the directory you are already looking at
|
||||
needs no click at all. */
|
||||
function chooseDirectory(profileId, current, onPick) {
|
||||
var dialog = document.createElement("dialog");
|
||||
dialog.className = "dialog dialog--wide";
|
||||
dialog.innerHTML =
|
||||
'<div class="dialog__form">' +
|
||||
'<h2 class="dialog__title">Project directory</h2>' +
|
||||
'<p class="dialog__note">Where this chat starts, and what a relative path ' +
|
||||
"is measured from. You can walk anywhere the account can reach.</p>" +
|
||||
'<div class="dialog__results"></div>' +
|
||||
'<input class="input input--mono" type="text" spellcheck="false" ' +
|
||||
'aria-label="Path" placeholder="/project">' +
|
||||
'<div class="dialog__actions">' +
|
||||
'<button class="btn" type="button" data-dir-cancel>Cancel</button>' +
|
||||
'<button class="btn btn--primary" type="button" data-dir-use>Use this directory</button>' +
|
||||
"</div></div>";
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
var results = dialog.querySelector(".dialog__results");
|
||||
var typed = dialog.querySelector("input");
|
||||
var here = current || "";
|
||||
|
||||
function load(path) {
|
||||
results.setAttribute("aria-busy", "true");
|
||||
fetch(
|
||||
"/api/agents/" + encodeURIComponent(profileId) +
|
||||
"/browse?path=" + encodeURIComponent(path || ""),
|
||||
{ credentials: "same-origin" }
|
||||
)
|
||||
.then(function (response) { return response.text(); })
|
||||
.then(function (html) {
|
||||
results.innerHTML = html;
|
||||
var box = results.querySelector("#dir-results");
|
||||
/* The server decides where we ended up -- it resolved the empty
|
||||
path to the profile's own default -- so the typed field follows
|
||||
it rather than the other way round. */
|
||||
if (box) { here = box.dataset.here || path || ""; typed.value = here; }
|
||||
results.removeAttribute("aria-busy");
|
||||
})
|
||||
.catch(function () {
|
||||
results.textContent = "Could not reach that machine.";
|
||||
results.removeAttribute("aria-busy");
|
||||
});
|
||||
}
|
||||
|
||||
results.addEventListener("click", function (event) {
|
||||
var row = event.target.closest("[data-dir-open]");
|
||||
if (!row) return;
|
||||
load(row.dataset.dirOpen);
|
||||
});
|
||||
|
||||
/* Typing a path you already know beats clicking to it, so the field is a
|
||||
first-class way in and not only a display of where you are. */
|
||||
typed.addEventListener("keydown", function (event) {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
load(typed.value.trim());
|
||||
});
|
||||
|
||||
function finish(chosen) {
|
||||
if (chosen !== undefined) onPick(chosen);
|
||||
dialog.close();
|
||||
setTimeout(function () { dialog.remove(); }, 200);
|
||||
}
|
||||
|
||||
dialog.querySelector("[data-dir-use]").addEventListener("click", function () {
|
||||
finish(typed.value.trim() || here);
|
||||
});
|
||||
dialog.querySelector("[data-dir-cancel]").addEventListener("click", function () {
|
||||
finish();
|
||||
});
|
||||
dialog.addEventListener("cancel", function (event) {
|
||||
event.preventDefault();
|
||||
finish();
|
||||
});
|
||||
dialog.addEventListener("click", function (event) {
|
||||
if (event.target === dialog) finish();
|
||||
});
|
||||
|
||||
dialog.showModal();
|
||||
load(current || "");
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (event) {
|
||||
var choice = event.target.closest("[data-attach]");
|
||||
if (!choice) return;
|
||||
@@ -411,6 +501,7 @@
|
||||
scrollThread: scrollThread,
|
||||
autosize: autosize,
|
||||
uploadFiles: uploadFiles,
|
||||
chooseDirectory: chooseDirectory,
|
||||
promptInstall: promptInstall
|
||||
};
|
||||
|
||||
@@ -482,6 +573,10 @@
|
||||
Shift and Enter should mean "new line". */
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key !== "Enter" || event.shiftKey) return;
|
||||
/* The Enter that commits an IME composition is not the Enter that sends.
|
||||
Typing Japanese or Chinese, every accepted candidate would otherwise
|
||||
post the half-written message. */
|
||||
if (event.isComposing || event.keyCode === 229) return;
|
||||
var composer = event.target.closest("[data-composer-input]");
|
||||
if (!composer) return;
|
||||
if (window.matchMedia("(pointer: coarse)").matches) return;
|
||||
|
||||
@@ -40,18 +40,39 @@
|
||||
}
|
||||
|
||||
/* --- Theme -------------------------------------------------------------- */
|
||||
/* The sixteen ANSI slots, in the order xterm names them. A shell chooses
|
||||
these itself -- `ls --color`, a git diff, htop's meters -- so without them
|
||||
the panel rendered a foreign palette inside the application, and switching
|
||||
to Shire left dark-theme colours on parchment. */
|
||||
var ANSI = [
|
||||
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
|
||||
"brightBlack", "brightRed", "brightGreen", "brightYellow",
|
||||
"brightBlue", "brightMagenta", "brightCyan", "brightWhite"
|
||||
];
|
||||
|
||||
function readTheme() {
|
||||
var style = getComputedStyle(document.documentElement);
|
||||
function token(name, fallback) {
|
||||
return (style.getPropertyValue(name) || "").trim() || fallback;
|
||||
}
|
||||
return {
|
||||
var theme = {
|
||||
background: token("--code-bg", "#0C0F13"),
|
||||
foreground: token("--ink", "#E8E2D4"),
|
||||
cursor: token("--accent", "#C9A227"),
|
||||
foreground: token("--ink", "#E4E8EC"),
|
||||
cursor: token("--accent", "#8FB3CC"),
|
||||
cursorAccent: token("--code-bg", "#0C0F13"),
|
||||
selectionBackground: token("--accent-soft", "rgba(201, 162, 39, 0.3)")
|
||||
selectionBackground: token("--accent-soft", "rgba(143, 179, 204, 0.3)")
|
||||
};
|
||||
/* camelCase to --kebab-case: brightBlack -> --ansi-bright-black. A slot
|
||||
with no token is left off the object entirely rather than set to
|
||||
undefined, which xterm treats as a colour and renders as black. */
|
||||
ANSI.forEach(function (name) {
|
||||
var value = token(
|
||||
"--ansi-" + name.replace(/[A-Z]/g, function (c) { return "-" + c.toLowerCase(); }),
|
||||
""
|
||||
);
|
||||
if (value) theme[name] = value;
|
||||
});
|
||||
return theme;
|
||||
}
|
||||
|
||||
/* --- Sizing ------------------------------------------------------------- */
|
||||
@@ -163,13 +184,16 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
var style = getComputedStyle(document.documentElement);
|
||||
term = new Terminal({
|
||||
allowProposedApi: true,
|
||||
convertEol: false,
|
||||
cursorBlink: true,
|
||||
fontFamily: getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--font-mono").trim() || "monospace",
|
||||
fontSize: 13,
|
||||
fontFamily: style.getPropertyValue("--font-mono").trim() || "monospace",
|
||||
/* xterm wants a number, so the token has to be a plain pixel value and
|
||||
is parsed back out here. Reading it rather than repeating 13 is what
|
||||
keeps it adjustable in one place with everything else. */
|
||||
fontSize: parseFloat(style.getPropertyValue("--terminal-font-size")) || 13,
|
||||
scrollback: 5000,
|
||||
theme: readTheme()
|
||||
});
|
||||
|
||||
@@ -333,6 +333,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
/* A menu item is an action, so the menu has served its purpose the moment
|
||||
one is pressed. Only `choose` used to close anything, which left the
|
||||
attach menu standing open over the composer after picking from it. The
|
||||
item's own handler -- htmx, or the [data-attach] and [data-toggle]
|
||||
delegates in app.js -- still runs; this only puts the menu away. */
|
||||
var item = event.target.closest('[data-picker-menu] [role="menuitem"]');
|
||||
if (item) {
|
||||
close(item.closest("[data-picker]"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.target.closest("[data-picker-menu]")) closeAll(null);
|
||||
});
|
||||
|
||||
@@ -489,13 +500,21 @@ document.addEventListener("lembas:notify", function (event) {
|
||||
*/
|
||||
(function () {
|
||||
function wire(root) {
|
||||
var kind = root.querySelector("#chat-kind") ||
|
||||
root.parentNode.querySelector("#chat-kind");
|
||||
var extra = root.querySelector(".composer__kind-agent");
|
||||
var kind = root.querySelector("#chat-kind");
|
||||
var extra = root.querySelector("[data-agent-extra]");
|
||||
var picker = root.querySelector('select[name="ssh_profile_id"]');
|
||||
var dir = root.querySelector('input[name="project_dir"]');
|
||||
var dir = root.querySelector("[data-dir-value]");
|
||||
var dirLabel = root.querySelector("[data-dir-label]");
|
||||
if (!kind || !extra) return;
|
||||
|
||||
/* The directory is a hidden field plus a button, so the two have to be set
|
||||
together or the button shows one path and the form submits another. */
|
||||
function setDir(value) {
|
||||
if (!dir) return;
|
||||
dir.value = value || "";
|
||||
if (dirLabel) dirLabel.textContent = value || "the login directory";
|
||||
}
|
||||
|
||||
function sync() {
|
||||
var chosen = root.querySelector('input[name="kind_choice"]:checked');
|
||||
var agent = chosen && chosen.value === "agent";
|
||||
@@ -503,17 +522,37 @@ document.addEventListener("lembas:notify", function (event) {
|
||||
extra.hidden = !agent;
|
||||
}
|
||||
|
||||
function profileDefault() {
|
||||
var option = picker && picker.options[picker.selectedIndex];
|
||||
return (option && option.dataset.dir) || "";
|
||||
}
|
||||
|
||||
root.addEventListener("change", function (event) {
|
||||
if (event.target.name === "kind_choice") sync();
|
||||
// Following the profile's own directory is a convenience, not a rule:
|
||||
// once someone has typed their own it is left alone.
|
||||
// once someone has chosen their own it is left alone.
|
||||
if (event.target === picker && dir && !dir.dataset.touched) {
|
||||
var option = picker.options[picker.selectedIndex];
|
||||
dir.value = (option && option.dataset.dir) || "";
|
||||
setDir(profileDefault());
|
||||
}
|
||||
});
|
||||
if (dir) dir.addEventListener("input", function () { dir.dataset.touched = "1"; });
|
||||
|
||||
root.addEventListener("click", function (event) {
|
||||
if (!event.target.closest("[data-dir-browse]")) return;
|
||||
event.preventDefault();
|
||||
var profileId = picker ? picker.value : "";
|
||||
if (!profileId) return;
|
||||
window.lembas.chooseDirectory(profileId, dir.value, function (chosen) {
|
||||
dir.dataset.touched = "1";
|
||||
setDir(chosen);
|
||||
});
|
||||
});
|
||||
|
||||
sync();
|
||||
/* Seeded from whichever profile the select is actually showing, not from
|
||||
the first in the list -- an unverified first profile renders `disabled`,
|
||||
so the two disagreed and the box offered a directory on a machine the
|
||||
chat was not going to use. */
|
||||
setDir(profileDefault());
|
||||
}
|
||||
|
||||
function scan() {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
One directory on the far side, inside the composer's directory dialog.
|
||||
|
||||
Swapped in whole on every step rather than filtered in the browser: a
|
||||
directory listing is a round trip to somebody else's machine, and there is no
|
||||
local copy to filter. `here` is what the Use button submits, so it is carried
|
||||
on the container rather than recomputed from whatever row was last clicked.
|
||||
|
||||
Everything here is a name from a remote filesystem, so everything is escaped
|
||||
by Jinja's autoescaping and none of it is ever marked safe.
|
||||
#}
|
||||
<div id="dir-results" data-here="{{ here }}">
|
||||
<p class="dialog__where">
|
||||
{{ icon("folder-open", "icon--sm") }}
|
||||
<span class="mono">{{ here }}</span>
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert--danger">{{ error }}</div>
|
||||
{% else %}
|
||||
<ul class="picker__list">
|
||||
{% if parent %}
|
||||
<li>
|
||||
<button class="picker__option" type="button" data-dir-open="{{ parent }}">
|
||||
{{ icon("chevron-left", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Up a level</span>
|
||||
<span class="picker__option-note mono">{{ parent }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for entry in entries %}
|
||||
{# Files are listed but not selectable. Hiding them would make a directory
|
||||
of only files look empty, which is worse than showing what is there and
|
||||
not letting it be chosen. #}
|
||||
<li>
|
||||
{% if entry.is_dir %}
|
||||
<button class="picker__option" type="button"
|
||||
data-dir-open="{{ (here.rstrip('/') ~ '/' ~ entry.name) if here != '/' else '/' ~ entry.name }}">
|
||||
{{ icon("folder", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">{{ entry.name }}</span>
|
||||
</span>
|
||||
{{ icon("chevron-right", "icon--sm") }}
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="picker__option is-inert">
|
||||
{{ icon("file-text", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">{{ entry.name }}</span>
|
||||
</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
{% if not entries %}
|
||||
<li>
|
||||
<p class="muted text-sm" style="padding: var(--sp-3)">
|
||||
Nothing here.
|
||||
</p>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -6,27 +6,27 @@
|
||||
message; removing the chip removes the input, which is all the bookkeeping
|
||||
the client needs.
|
||||
#}
|
||||
<div class="chip" id="chip-{{ attachment.id }}">
|
||||
<div class="attach-chip" id="chip-{{ attachment.id }}">
|
||||
<input type="hidden" name="file_ids" value="{{ attachment.id }}">
|
||||
|
||||
{% if attachment.is_image %}
|
||||
<img class="chip__thumb" src="/api/files/{{ attachment.id }}/content" alt="">
|
||||
<img class="attach-chip__thumb" src="/api/files/{{ attachment.id }}/content" alt="">
|
||||
{% else %}
|
||||
<span class="chip__icon">
|
||||
<span class="attach-chip__icon">
|
||||
{{ icon("attach" if attachment.kind == "document" else "copy", "icon--sm") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="chip__body">
|
||||
<span class="chip__name" title="{{ attachment.filename }}">{{ attachment.filename }}</span>
|
||||
<span class="chip__meta">
|
||||
<span class="attach-chip__body">
|
||||
<span class="attach-chip__name" title="{{ attachment.filename }}">{{ attachment.filename }}</span>
|
||||
<span class="attach-chip__meta">
|
||||
{{ attachment.human_size }}
|
||||
{%- if attachment.pages %} · {{ attachment.pages }} page{{ '' if attachment.pages == 1 else 's' }}{% endif %}
|
||||
{%- if attachment.width %} · {{ attachment.width }}×{{ attachment.height }}{% endif %}
|
||||
{%- if attachment.truncated %} · truncated{% endif %}
|
||||
</span>
|
||||
{% if attachment.extraction_error %}
|
||||
<span class="chip__warning">{{ attachment.extraction_error }}</span>
|
||||
<span class="attach-chip__warning">{{ attachment.extraction_error }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
the composer rather than only in the network tab. Dismissed by hand; it
|
||||
carries no hidden input, so it cannot be submitted with the message.
|
||||
#}
|
||||
<div class="chip chip--error">
|
||||
<span class="chip__icon">{{ icon("warning", "icon--sm") }}</span>
|
||||
<span class="chip__body">
|
||||
<span class="chip__name">{{ filename }}</span>
|
||||
<span class="chip__warning">{{ error }}</span>
|
||||
<div class="attach-chip attach-chip--error">
|
||||
<span class="attach-chip__icon">{{ icon("warning", "icon--sm") }}</span>
|
||||
<span class="attach-chip__body">
|
||||
<span class="attach-chip__name">{{ filename }}</span>
|
||||
<span class="attach-chip__warning">{{ error }}</span>
|
||||
</span>
|
||||
<button class="btn btn--icon btn--sm" type="button" aria-label="Dismiss"
|
||||
onclick="this.closest('.chip').remove()">
|
||||
onclick="this.closest('.attach-chip').remove()">
|
||||
{{ icon("x", "icon--sm") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
file_ids input, and being inside the form is what gets them serialised with
|
||||
the message. Keeping them outside and reaching for hx-include does not work:
|
||||
that attribute only has an effect on the element issuing the request.
|
||||
|
||||
Layout: chips, then the text, then one toolbar row underneath carrying
|
||||
everything that acts on the message. The kind selector and the connection
|
||||
used to sit in a strip *above* the text, inside the same bordered card, where
|
||||
they read as debris floating in the input rather than as controls. Below the
|
||||
text they are in the same place as attach and send, which is where the hand
|
||||
already is.
|
||||
#}
|
||||
<div class="composer" {% if can.get("files.upload") %}data-dropzone{% endif %}>
|
||||
{% if can.get("files.upload") %}
|
||||
@@ -54,128 +61,186 @@
|
||||
<input type="hidden" name="temporary" value="true">
|
||||
{% endif %}
|
||||
|
||||
{# Chat or Agent, chosen once. There is no switching afterwards: the
|
||||
tools offered, the harness and the approval loop all differ, so a
|
||||
conversation whose earlier turns ran somewhere else is not one
|
||||
conversation. Only shown when picking Agent would lead anywhere. #}
|
||||
{% if not chat and agent_profiles %}
|
||||
<input type="hidden" name="kind" value="chat" id="chat-kind">
|
||||
<div class="composer__kind" data-agent-picker>
|
||||
<label class="chip">
|
||||
<input type="radio" name="kind_choice" value="chat" checked>
|
||||
<span>{{ icon("chat", "icon--sm") }} Chat</span>
|
||||
</label>
|
||||
<label class="chip">
|
||||
<input type="radio" name="kind_choice" value="agent">
|
||||
<span>{{ icon("server", "icon--sm") }} Agent</span>
|
||||
</label>
|
||||
<textarea class="composer__input" name="content" rows="1"
|
||||
data-autosize data-max-height="320" data-composer-input
|
||||
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
|
||||
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
|
||||
|
||||
<span class="composer__kind-agent" hidden>
|
||||
<select class="select select--sm" name="ssh_profile_id" aria-label="Connection">
|
||||
{% for profile in agent_profiles %}
|
||||
<option value="{{ profile.id }}" data-dir="{{ profile.default_dir }}"
|
||||
{{ 'disabled' if not profile.verified }}>
|
||||
{{ profile.name }}{{ ' — not checked' if not profile.verified }}
|
||||
</option>
|
||||
<div class="composer__toolbar">
|
||||
<div class="composer__tools">
|
||||
{% if can.get("files.upload") %}
|
||||
{# A menu rather than the file picker straight away: there are four ways
|
||||
to attach something now, and only one of them is a file on disk.
|
||||
Uses the same picker machinery as the model chooser -- see ui.js. #}
|
||||
<div class="picker picker--up" data-picker>
|
||||
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
||||
aria-haspopup="menu" aria-expanded="false"
|
||||
aria-label="Attach" title="Attach">
|
||||
{{ icon("attach") }}
|
||||
</button>
|
||||
|
||||
<div class="picker__menu picker__menu--compact" data-picker-menu role="menu"
|
||||
hidden aria-label="Attach">
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="file">
|
||||
{{ icon("attach", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">File</span>
|
||||
<span class="picker__option-note">PDF, text, code</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="image">
|
||||
{{ icon("image", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Image</span>
|
||||
<span class="picker__option-note">Sent only to vision models</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="link">
|
||||
{{ icon("link", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Link</span>
|
||||
<span class="picker__option-note">Fetch a page and attach its text</span>
|
||||
</span>
|
||||
</button>
|
||||
{% if can.get("library.use") %}
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="knowledge">
|
||||
{{ icon("archive", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Knowledge</span>
|
||||
<span class="picker__option-note">From your library</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{#
|
||||
What this conversation is and where it runs.
|
||||
|
||||
On a new chat all of it is editable. On an existing one the kind, the
|
||||
connection and the directory are fixed -- update_chat refuses them
|
||||
with a 409, because a transcript whose earlier turns ran somewhere
|
||||
else is not one conversation -- so they render as a read-only chip and
|
||||
only the mode stays live. The mode is the exception on purpose: it
|
||||
decides what gets asked about, not what the conversation is.
|
||||
#}
|
||||
{% if not chat and agent_profiles %}
|
||||
<div class="composer__context" data-agent-picker>
|
||||
<input type="hidden" name="kind" value="chat" id="chat-kind">
|
||||
|
||||
<div class="segmented" role="group" aria-label="Kind of chat">
|
||||
<label class="segmented__option">
|
||||
<input type="radio" name="kind_choice" value="chat" checked>
|
||||
<span>{{ icon("chat", "icon--sm") }} Chat</span>
|
||||
</label>
|
||||
<label class="segmented__option">
|
||||
<input type="radio" name="kind_choice" value="agent">
|
||||
<span>{{ icon("bolt", "icon--sm") }} Agent</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<span class="composer__agent" data-agent-extra hidden>
|
||||
<select class="select select--sm" name="ssh_profile_id" aria-label="Connection">
|
||||
{% for profile in agent_profiles %}
|
||||
<option value="{{ profile.id }}" data-dir="{{ profile.default_dir }}"
|
||||
{{ 'disabled' if not profile.verified }}>
|
||||
{{ profile.name }}{{ ' — not checked' if not profile.verified }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
{# A button and a hidden field, not a text box. The text box was
|
||||
real and submitted, but unlabelled and squeezed to a few
|
||||
characters by the select beside it, so it read as broken. A path
|
||||
is also something you would rather find than spell. #}
|
||||
<input type="hidden" name="project_dir" value="" data-dir-value>
|
||||
<button class="btn btn--sm composer__dir" type="button" data-dir-browse
|
||||
aria-label="Project directory"
|
||||
title="Choose the directory this chat works in">
|
||||
{{ icon("folder", "icon--sm") }}
|
||||
<span class="composer__dir-path" data-dir-label>/</span>
|
||||
</button>
|
||||
|
||||
{% if agent_modes %}
|
||||
<select class="select select--sm" name="agent_mode" aria-label="Approval mode">
|
||||
{% for value, label, hint in agent_modes %}
|
||||
<option value="{{ value }}" title="{{ hint }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% elif chat and chat.kind == "agent" %}
|
||||
<div class="composer__context">
|
||||
<span class="composer__where" title="{{ chat.project_dir }}">
|
||||
{{ icon("bolt", "icon--sm") }}
|
||||
<span>{{ agent_profile.name if agent_profile else "connection missing" }}</span>
|
||||
<span class="composer__where-dir">{{ chat.project_dir }}</span>
|
||||
</span>
|
||||
|
||||
{# Its own form: nesting one inside the composer's form is invalid
|
||||
HTML, and the browser drops the inner one. #}
|
||||
<select class="select select--sm" name="agent_mode" aria-label="Approval mode"
|
||||
form="agent-mode-form">
|
||||
{% for value, label, hint in agent_modes %}
|
||||
<option value="{{ value }}" title="{{ hint }}"
|
||||
{{ 'selected' if value == chat.agent_mode }}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input class="input input--sm input--mono" name="project_dir"
|
||||
value="{{ agent_profiles[0].default_dir }}"
|
||||
aria-label="Project directory" placeholder="/project">
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="composer__row">
|
||||
{% if can.get("files.upload") %}
|
||||
{# A menu rather than the file picker straight away: there are four ways
|
||||
to attach something now, and only one of them is a file on disk.
|
||||
Uses the same picker machinery as the model chooser -- see ui.js. #}
|
||||
<div class="picker picker--up" data-picker>
|
||||
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
||||
aria-haspopup="menu" aria-expanded="false"
|
||||
aria-label="Attach" title="Attach">
|
||||
{{ icon("attach") }}
|
||||
</button>
|
||||
|
||||
<div class="picker__menu picker__menu--compact" data-picker-menu role="menu"
|
||||
hidden aria-label="Attach">
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="file">
|
||||
{{ icon("attach", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">File</span>
|
||||
<span class="picker__option-note">PDF, text, code</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="image">
|
||||
{{ icon("image", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Image</span>
|
||||
<span class="picker__option-note">Sent only to vision models</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="link">
|
||||
{{ icon("link", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Link</span>
|
||||
<span class="picker__option-note">Fetch a page and attach its text</span>
|
||||
</span>
|
||||
</button>
|
||||
{% if can.get("library.use") %}
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="knowledge">
|
||||
{{ icon("archive", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Knowledge</span>
|
||||
<span class="picker__option-note">From your library</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<textarea class="composer__input" name="content" rows="1"
|
||||
data-autosize data-max-height="320" data-composer-input
|
||||
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
|
||||
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
|
||||
<div class="composer__actions">
|
||||
{% if can_dictate %}
|
||||
{# Recording is started and stopped by the same button; audio.js swaps
|
||||
data-mic-state and the icon with it. #}
|
||||
<button class="btn btn--icon composer__btn composer__mic" type="button"
|
||||
data-mic data-mic-state="idle"
|
||||
aria-label="Dictate a message" title="Dictate a message">
|
||||
<span class="composer__icon composer__icon--mic">{{ icon("mic") }}</span>
|
||||
<span class="composer__icon composer__icon--recording" aria-hidden="true">
|
||||
{{ icon("stop-circle") }}
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if can_dictate %}
|
||||
{# Recording is started and stopped by the same button; audio.js swaps
|
||||
data-mic-state and the icon with it. #}
|
||||
<button class="btn btn--icon composer__btn composer__mic" type="button"
|
||||
data-mic data-mic-state="idle"
|
||||
aria-label="Dictate a message" title="Dictate a message">
|
||||
<span class="composer__icon composer__icon--mic">{{ icon("mic") }}</span>
|
||||
<span class="composer__icon composer__icon--recording" aria-hidden="true">
|
||||
{{ icon("stop-circle") }}
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{#
|
||||
One button, two jobs. While a reply is being written it becomes Stop,
|
||||
because that is where the hand already is and a second button sitting
|
||||
permanently beside Send is clutter that is wrong most of the time.
|
||||
|
||||
{#
|
||||
One button, two jobs. While a reply is being written it becomes Stop,
|
||||
because that is where the hand already is and a second button sitting
|
||||
permanently beside Send is clutter that is wrong most of the time.
|
||||
|
||||
ui.js flips data-composer-action, and the type with it: as `submit`
|
||||
the form's own handler sends, as `button` the click handler stops.
|
||||
Both icons are rendered here and chosen in CSS, so the swap costs no
|
||||
layout and cannot flash an empty button.
|
||||
#}
|
||||
<button class="btn btn--primary btn--icon composer__btn" type="submit"
|
||||
data-composer-action="send" aria-label="Send">
|
||||
<span class="composer__icon composer__icon--send">{{ icon("send") }}</span>
|
||||
<span class="composer__icon composer__icon--stop" aria-hidden="true">
|
||||
<span class="composer__stop-square"></span>
|
||||
</span>
|
||||
</button>
|
||||
ui.js flips data-composer-action, and the type with it: as `submit`
|
||||
the form's own handler sends, as `button` the click handler stops.
|
||||
Both icons are rendered here and chosen in CSS, so the swap costs no
|
||||
layout and cannot flash an empty button.
|
||||
#}
|
||||
<button class="btn btn--primary btn--icon composer__btn" type="submit"
|
||||
data-composer-action="send" aria-label="Send">
|
||||
<span class="composer__icon composer__icon--send">{{ icon("send") }}</span>
|
||||
<span class="composer__icon composer__icon--stop" aria-hidden="true">
|
||||
<span class="composer__stop-square"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# Outside the composer's form, and referenced by the mode select's `form`
|
||||
attribute above. hx-patch and not hx-post: there is no POST for a chat,
|
||||
only PATCH, and htmx shows nothing when a request 405s -- which is how
|
||||
this control spent its whole life doing nothing. #}
|
||||
{% if chat and chat.kind == "agent" %}
|
||||
<form id="agent-mode-form" hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||
hx-trigger="change"></form>
|
||||
{% endif %}
|
||||
|
||||
<p class="composer__hint">
|
||||
Enter to send, Shift+Enter for a new line.
|
||||
{% if can.get("files.upload") %}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
opened and never on a page load nobody looked at.
|
||||
#}
|
||||
<aside class="inspector" id="inspector" hidden aria-label="Request inspector">
|
||||
<div class="inspector__header">
|
||||
<h2 class="inspector__title">{{ icon("search", "icon--sm") }} Inspector</h2>
|
||||
<div class="panel-head">
|
||||
<h2 class="panel-head__title">{{ icon("search", "icon--sm") }} Inspector</h2>
|
||||
<button class="btn btn--icon btn--sm" type="button" data-toggle="#inspector"
|
||||
aria-label="Close inspector">
|
||||
{{ icon("x", "icon--sm") }}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
{{ icon("attach", "icon--sm") }}
|
||||
<span class="attachments__doc-body">
|
||||
<a href="/api/files/{{ attachment.id }}/content">{{ attachment.filename }}</a>
|
||||
<span class="chip__meta">
|
||||
<span class="attach-chip__meta">
|
||||
{{ attachment.human_size }}
|
||||
{%- if attachment.pages %} · {{ attachment.pages }} page{{ '' if attachment.pages == 1 else 's' }}{% endif %}
|
||||
{%- if attachment.truncated %} · truncated{% endif %}
|
||||
@@ -77,7 +77,7 @@
|
||||
{%- endif %}
|
||||
</span>
|
||||
{% if attachment.extraction_error %}
|
||||
<span class="chip__warning">{{ attachment.extraction_error }}</span>
|
||||
<span class="attach-chip__warning">{{ attachment.extraction_error }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
data-url="/api/chats/{{ chat.id }}/terminal/ws"
|
||||
data-label="{{ agent_profile.name if agent_profile else 'this connection' }}"
|
||||
data-dir="{{ chat.project_dir }}">
|
||||
<div class="terminal__header">
|
||||
<h2 class="terminal__title">
|
||||
<div class="panel-head">
|
||||
<h2 class="panel-head__title">
|
||||
{{ icon("terminal", "icon--sm") }}
|
||||
<span>{{ agent_profile.name if agent_profile else "Terminal" }}</span>
|
||||
<span class="terminal__where" data-terminal-where>{{ chat.project_dir }}</span>
|
||||
|
||||
@@ -25,47 +25,22 @@
|
||||
|
||||
<h1 class="topbar__title">
|
||||
<span id="chat-title">{{ chat.title if chat else "New chat" }}</span>
|
||||
</h1>
|
||||
|
||||
{# The mode is the one agent setting that changes mid-chat: it decides
|
||||
what gets asked about, not what the conversation is. In the header
|
||||
rather than the settings panel because it is looked at constantly --
|
||||
it is the difference between being interrupted and not. #}
|
||||
{% if chat and chat.kind == "agent" %}
|
||||
<form class="agent-bar" hx-post="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||
hx-trigger="change">
|
||||
<span class="agent-bar__where" title="{{ chat.project_dir }}">
|
||||
{{ icon("server", "icon--sm") }}
|
||||
{{ agent_profile.name if agent_profile else "connection missing" }}
|
||||
</span>
|
||||
<select class="select select--sm" name="agent_mode" aria-label="Mode">
|
||||
{% for value, label, hint in agent_modes %}
|
||||
<option value="{{ value }}" title="{{ hint }}"
|
||||
{{ 'selected' if value == chat.agent_mode }}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="topbar__actions">
|
||||
{#
|
||||
A link, not a script: the flag lives in the URL, so it survives a
|
||||
reload and can be bookmarked. On an existing temporary chat the same
|
||||
corner explains what temporary means and offers the way out -- without
|
||||
one, a conversation that turns out to matter is destroyed a day later
|
||||
with no recourse.
|
||||
#}
|
||||
{# Beside the title because it describes the chat rather than acting on
|
||||
it. The way out of it is Keep, in the overflow menu. #}
|
||||
{% if chat and chat.temporary %}
|
||||
<span class="badge badge--warning"
|
||||
title="Not listed in the sidebar, and removed 24 hours after the last message.">
|
||||
Temporary
|
||||
</span>
|
||||
<button class="btn btn--sm" type="button"
|
||||
hx-post="/api/chats/{{ chat.id }}/keep" hx-swap="none"
|
||||
title="Keep this chat and list it in the sidebar">
|
||||
{{ icon("pin", "icon--sm") }} Keep
|
||||
</button>
|
||||
{% elif not chat and can.get("chat.create") %}
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<div class="topbar__actions">
|
||||
{#
|
||||
A link, not a script: the flag lives in the URL, so it survives a
|
||||
reload and can be bookmarked.
|
||||
#}
|
||||
{% if not chat and can.get("chat.create") %}
|
||||
<a class="btn btn--icon {{ 'is-active' if starting_temporary }}"
|
||||
href="{{ '/chat' if starting_temporary else '/chat?temporary=1' }}"
|
||||
aria-label="Temporary chat"
|
||||
@@ -82,25 +57,6 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if chat and (can.get("chat.system_prompt") or can.get("chat.params")) %}
|
||||
<button class="btn btn--icon" type="button" aria-label="Chat settings"
|
||||
title="Chat settings" data-toggle="#chat-settings">
|
||||
{{ icon("sliders") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if chat and messages %}
|
||||
<button class="btn btn--icon" type="button" aria-label="Compact this chat"
|
||||
title="Summarise the earlier messages so they stop taking up context"
|
||||
hx-post="/api/chats/{{ chat.id }}/compact"
|
||||
hx-target="#thread" hx-swap="innerHTML"
|
||||
hx-confirm="Summarise everything before the last reply? The messages stay in the transcript; they just stop being sent to the model."
|
||||
data-confirm-title="Compact this chat"
|
||||
data-confirm-label="Compact">
|
||||
{{ icon("archive") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if terminal_enabled %}
|
||||
{# To the left of the inspector, and never open beside it: see the
|
||||
toggle group in app.js. #}
|
||||
@@ -118,6 +74,68 @@
|
||||
{{ icon("search") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{#
|
||||
Everything else about this chat, behind one button.
|
||||
|
||||
The bar carried eight controls, which is a toolbar nobody can read at
|
||||
a glance. What is left in it is what you reach for while writing --
|
||||
the model, and the two panels. These three are occasional, and every
|
||||
one of them is also a slash command now.
|
||||
#}
|
||||
{% if chat %}
|
||||
<div class="picker" data-picker>
|
||||
<button class="btn btn--icon" type="button" data-picker-toggle
|
||||
aria-haspopup="menu" aria-expanded="false"
|
||||
aria-label="More" title="More">
|
||||
{{ icon("dots") }}
|
||||
</button>
|
||||
|
||||
<div class="picker__menu picker__menu--compact" data-picker-menu role="menu"
|
||||
hidden aria-label="More">
|
||||
{% if chat.temporary %}
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
hx-post="/api/chats/{{ chat.id }}/keep" hx-swap="none">
|
||||
{{ icon("pin", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Keep this chat</span>
|
||||
<span class="picker__option-note">
|
||||
Temporary — removed 24 hours after the last message
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if can.get("chat.system_prompt") or can.get("chat.params") %}
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-toggle="#chat-settings">
|
||||
{{ icon("sliders", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Chat settings</span>
|
||||
<span class="picker__option-note">Prompt, knowledge, sampling</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if messages %}
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
hx-post="/api/chats/{{ chat.id }}/compact"
|
||||
hx-target="#thread" hx-swap="innerHTML"
|
||||
hx-confirm="Summarise everything before the last reply? The messages stay in the transcript; they just stop being sent to the model."
|
||||
data-confirm-title="Compact this chat"
|
||||
data-confirm-label="Compact">
|
||||
{{ icon("archive", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Compact</span>
|
||||
<span class="picker__option-note">
|
||||
Summarise the earlier turns so they stop costing context
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -165,6 +165,48 @@
|
||||
<path d="M13.5 10.5a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 0 0 5 5L12 17"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-chevron-left" viewBox="0 0 24 24">
|
||||
<path d="M14.5 6 8.5 12l6 6"/>
|
||||
</symbol>
|
||||
<symbol id="i-chevron-up" viewBox="0 0 24 24">
|
||||
<path d="M6 14.5 12 8.5l6 6"/>
|
||||
</symbol>
|
||||
|
||||
<!-- @, for a mention. The tail stops short of closing so the ring reads as a
|
||||
ring at 16px rather than as a filled blob. -->
|
||||
<symbol id="i-at" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="3.6"/>
|
||||
<path d="M15.6 8.4v4.9a2.6 2.6 0 0 0 5.2 0V12a8.8 8.8 0 1 0-3.5 7"/>
|
||||
</symbol>
|
||||
|
||||
<!-- The command key, which is what a command menu is. -->
|
||||
<symbol id="i-command" viewBox="0 0 24 24">
|
||||
<path d="M9 9h6v6H9z"/>
|
||||
<path d="M9 9V7.5a2.5 2.5 0 1 0-2.5 2.5H9Zm6 0V7.5a2.5 2.5 0 1 1 2.5 2.5H15ZM9 15v1.5A2.5 2.5 0 1 1 6.5 14H9Zm6 0v1.5a2.5 2.5 0 1 0 2.5-2.5H15Z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-keyboard" viewBox="0 0 24 24">
|
||||
<rect x="2.5" y="6" width="19" height="12" rx="2"/>
|
||||
<path d="M6 9.5h.01M9 9.5h.01M12 9.5h.01M15 9.5h.01M18 9.5h.01M6 12.5h.01M9 12.5h.01M12 12.5h.01M15 12.5h.01M18 12.5h.01M8.5 15.5h7"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-file-text" viewBox="0 0 24 24">
|
||||
<path d="M13.5 3.5H7a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9l-5.5-5.5Z"/>
|
||||
<path d="M13.5 3.5V9H19M8.5 13h7M8.5 16.5h7"/>
|
||||
</symbol>
|
||||
|
||||
<!-- A drag handle. Dots rather than lines: lines at this size read as a
|
||||
hamburger, which means something else entirely. -->
|
||||
<symbol id="i-grip" viewBox="0 0 24 24">
|
||||
<path d="M10 6h.01M10 12h.01M10 18h.01M14 6h.01M14 12h.01M14 18h.01"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Agent chats. They borrowed `server`, which is the machine rather than
|
||||
the act of working on one. -->
|
||||
<symbol id="i-bolt" viewBox="0 0 24 24">
|
||||
<path d="M13 2.5 4.5 13.5H11l-1 8 8.5-11H12l1-8Z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-leaf" viewBox="0 0 64 64">
|
||||
<path d="M20.5 45.5C13.8 31.7 23.8 20.9 45.5 18.5 49.8 35 39.8 45.8 20.5 45.5Z"/>
|
||||
<path d="M20.5 45.5C28 38 36 29 45.5 18.5"/>
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Walking a directory on the far side, to choose where a chat works.
|
||||
|
||||
Everything here goes through a real SFTP server on 127.0.0.1. A stub would
|
||||
prove nothing worth proving: the whole point of `scan_dir` is that it reads
|
||||
`SFTPName.attrs` to tell a directory from a file, and a stub that returns
|
||||
whatever shape the test wants cannot be wrong about that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import SshProfile, User
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
|
||||
asyncssh = pytest.importorskip("asyncssh")
|
||||
|
||||
|
||||
class _Server(asyncssh.SSHServer):
|
||||
def begin_auth(self, username: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def tree(tmp_path):
|
||||
"""A real sshd with SFTP, over a directory laid out to be walked."""
|
||||
root = tmp_path / "project"
|
||||
(root / "src" / "deep").mkdir(parents=True)
|
||||
(root / "docs").mkdir()
|
||||
(root / "README.md").write_text("hello")
|
||||
(root / "src" / "main.py").write_text("print()")
|
||||
(root / ".hidden").mkdir()
|
||||
|
||||
server = await asyncssh.create_server(
|
||||
_Server,
|
||||
"127.0.0.1",
|
||||
0,
|
||||
server_host_keys=[asyncssh.generate_private_key("ssh-ed25519")],
|
||||
sftp_factory=True,
|
||||
)
|
||||
port = next(iter(server.sockets)).getsockname()[1]
|
||||
line, fingerprint = await ssh_service.capture_host_key("127.0.0.1", port)
|
||||
try:
|
||||
yield {"port": port, "host_key": line, "fingerprint": fingerprint, "root": str(root)}
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
|
||||
def _spec(tree: dict) -> dict:
|
||||
return {
|
||||
"host": "127.0.0.1",
|
||||
"port": tree["port"],
|
||||
"username": "tester",
|
||||
"auth": "key",
|
||||
"password": "",
|
||||
"private_key": "",
|
||||
"key_passphrase": "",
|
||||
"host_key": tree["host_key"],
|
||||
"connect_timeout": 15,
|
||||
}
|
||||
|
||||
|
||||
# --- scan_dir ----------------------------------------------------------------
|
||||
async def test_a_listing_says_which_rows_are_directories(tree):
|
||||
"""The whole reason this exists beside `list_dir`, which returns names."""
|
||||
executor = ssh_service.SshExecutor(_spec(tree), tree["root"])
|
||||
entries = await executor.scan_dir("")
|
||||
|
||||
kinds = {entry.name: entry.is_dir for entry in entries}
|
||||
assert kinds["src"] is True
|
||||
assert kinds["docs"] is True
|
||||
assert kinds["README.md"] is False
|
||||
|
||||
|
||||
async def test_directories_sort_before_files(tree):
|
||||
"""The order somebody navigating expects: the things you can walk into."""
|
||||
executor = ssh_service.SshExecutor(_spec(tree), tree["root"])
|
||||
names = [entry.name for entry in await executor.scan_dir("")]
|
||||
|
||||
assert names.index("src") < names.index("README.md")
|
||||
assert names.index("docs") < names.index("README.md")
|
||||
|
||||
|
||||
async def test_files_carry_a_size(tree):
|
||||
executor = ssh_service.SshExecutor(_spec(tree), tree["root"])
|
||||
entries = {entry.name: entry for entry in await executor.scan_dir("")}
|
||||
|
||||
assert entries["README.md"].size == len("hello")
|
||||
|
||||
|
||||
async def test_dotfiles_are_listed(tree):
|
||||
"""Shown, not filtered. A picker that hides `.config` is one somebody has
|
||||
to work around, and the account on the far side is the boundary here --
|
||||
not a taste for tidy listings."""
|
||||
executor = ssh_service.SshExecutor(_spec(tree), tree["root"])
|
||||
names = [entry.name for entry in await executor.scan_dir("")]
|
||||
|
||||
assert ".hidden" in names
|
||||
assert "." not in names and ".." not in names
|
||||
|
||||
|
||||
async def test_a_relative_path_is_measured_from_the_project_directory(tree):
|
||||
executor = ssh_service.SshExecutor(_spec(tree), tree["root"])
|
||||
names = [entry.name for entry in await executor.scan_dir("src")]
|
||||
|
||||
assert names == ["deep", "main.py"]
|
||||
|
||||
|
||||
async def test_an_absolute_path_walks_out_of_the_project_directory(tree):
|
||||
"""Deliberate, and matching `_resolve`'s own docstring.
|
||||
|
||||
Containment is the far-side account's job, not this layer's: `shell_run`
|
||||
could leave the project directory in one line, so a picker that refused to
|
||||
would be a comfort rather than a control. The browser opens *at* the
|
||||
project directory; it does not fence it.
|
||||
"""
|
||||
executor = ssh_service.SshExecutor(_spec(tree), tree["root"])
|
||||
names = [entry.name for entry in await executor.scan_dir("/")]
|
||||
|
||||
assert names # the real filesystem root, listed without complaint
|
||||
|
||||
|
||||
async def test_a_missing_directory_says_so_in_words(tree):
|
||||
executor = ssh_service.SshExecutor(_spec(tree), tree["root"])
|
||||
|
||||
with pytest.raises(ssh_service.ExecError) as caught:
|
||||
await executor.scan_dir("nowhere")
|
||||
|
||||
assert "no directory" in caught.value.message.lower()
|
||||
|
||||
|
||||
# --- The route ---------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def served_tree(tmp_path):
|
||||
"""The same tree, but with the server on a thread and a loop of its own.
|
||||
|
||||
Its own loop matters: the tests below drive the app through the synchronous
|
||||
TestClient, so a server sharing the test's loop could never accept the
|
||||
connection the route is trying to make -- `client.get(...)` is still
|
||||
blocking it. The async fixture above is fine for calling `scan_dir`
|
||||
directly, and useless here.
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
root = tmp_path / "served"
|
||||
(root / "src").mkdir(parents=True)
|
||||
(root / "README.md").write_text("hello")
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
thread = threading.Thread(target=loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
async def start():
|
||||
server = await asyncssh.create_server(
|
||||
_Server,
|
||||
"127.0.0.1",
|
||||
0,
|
||||
server_host_keys=[asyncssh.generate_private_key("ssh-ed25519")],
|
||||
sftp_factory=True,
|
||||
)
|
||||
port = next(iter(server.sockets)).getsockname()[1]
|
||||
line, fingerprint = await ssh_service.capture_host_key("127.0.0.1", port)
|
||||
return server, port, line, fingerprint
|
||||
|
||||
server, port, line, fingerprint = asyncio.run_coroutine_threadsafe(start(), loop).result(10)
|
||||
try:
|
||||
yield {"port": port, "host_key": line, "fingerprint": fingerprint, "root": str(root)}
|
||||
finally:
|
||||
|
||||
async def stop():
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
asyncio.run_coroutine_threadsafe(stop(), loop).result(10)
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def _profile(db, tree: dict, *, host_key: str | None = None) -> SshProfile:
|
||||
user = db.scalars(select(User)).first()
|
||||
profile = SshProfile(
|
||||
owner_id=user.id,
|
||||
name="Test box",
|
||||
host="127.0.0.1",
|
||||
port=tree["port"],
|
||||
username="tester",
|
||||
host_key=tree["host_key"] if host_key is None else host_key,
|
||||
host_fingerprint=tree["fingerprint"],
|
||||
default_dir=tree["root"],
|
||||
)
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
return profile
|
||||
|
||||
|
||||
def test_browsing_lists_the_project_directory(client: TestClient, db, registered, served_tree):
|
||||
profile = _profile(db, served_tree)
|
||||
|
||||
body = client.get(f"/api/agents/{profile.id}/browse").text
|
||||
|
||||
assert "src" in body
|
||||
assert "README.md" in body
|
||||
assert served_tree["root"] in body
|
||||
|
||||
|
||||
def test_browsing_offers_a_way_back_up(client: TestClient, db, registered, served_tree):
|
||||
profile = _profile(db, served_tree)
|
||||
|
||||
body = client.get(f"/api/agents/{profile.id}/browse", params={"path": served_tree["root"]}).text
|
||||
|
||||
assert "Up a level" in body
|
||||
|
||||
|
||||
def test_a_host_key_that_was_never_confirmed_is_refused_in_words(
|
||||
client: TestClient, db, registered, served_tree
|
||||
):
|
||||
"""Rather than the known_hosts prose `connect_kwargs` would raise, which is
|
||||
accurate and means nothing to somebody looking at a directory picker."""
|
||||
profile = _profile(db, served_tree, host_key="")
|
||||
|
||||
body = client.get(f"/api/agents/{profile.id}/browse").text
|
||||
|
||||
assert "has not been confirmed" in body
|
||||
|
||||
|
||||
def test_somebody_elses_connection_is_not_browsable(
|
||||
client: TestClient, db, registered, served_tree
|
||||
):
|
||||
"""These are credentials to somebody's machine, so ownership is the whole
|
||||
authorisation -- `sharing.py` grants reading, and a host you can read is a
|
||||
host you can log in to.
|
||||
|
||||
The permission is granted to everybody first, so what is being tested is
|
||||
ownership and not the `agent.ssh` gate in front of it. Without that the
|
||||
second account is refused before the question is even asked, and the test
|
||||
would pass whether or not the ownership check existed.
|
||||
"""
|
||||
from lembas.services import settings_store
|
||||
|
||||
profile = _profile(db, served_tree)
|
||||
settings_store.update(db, {"default_permissions": {"agent.ssh": True}})
|
||||
|
||||
client.post("/auth/logout", follow_redirects=False)
|
||||
client.post(
|
||||
"/auth/register",
|
||||
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
# 404 and not 403: whether that connection exists is not this endpoint's to
|
||||
# reveal to somebody who does not own it.
|
||||
assert client.get(f"/api/agents/{profile.id}/browse").status_code == 404
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Choosing the approval mode: after a chat exists, and before one does.
|
||||
|
||||
The regression test at the top is the one that was missing. The mode select in
|
||||
the topbar posted with `hx-post` against a route that only answers `PATCH`, so
|
||||
every change returned 405 and the mode never moved -- and htmx surfaces nothing
|
||||
on a failed request, so the control looked like it had worked. A control wired
|
||||
to a method the route does not serve fails exactly this quietly, which is why
|
||||
the assertion below reads the row rather than the response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import KIND_AGENT, Chat, Connection, Model, SshProfile, User
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import policy
|
||||
|
||||
|
||||
def _agent_chat(db, *, mode: str = policy.MODE_MANUAL) -> Chat:
|
||||
"""An agent chat pointed at a profile that is never actually connected to.
|
||||
|
||||
Nothing here opens a connection: changing the mode is a database write, and
|
||||
a real sshd would only make the test slower and flakier.
|
||||
|
||||
A model is needed even though nothing generates: with none, `index.html`
|
||||
renders the "no models available" screen *instead of* the thread and the
|
||||
composer, so a test asserting on composer markup would pass against a page
|
||||
that does not contain a composer at all. That is not hypothetical -- it is
|
||||
how the first version of the assertion below came to be vacuous.
|
||||
"""
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
|
||||
user = db.scalars(select(User)).first()
|
||||
assert user is not None
|
||||
|
||||
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
db.add(Model(connection_id=connection.id, model_id="m"))
|
||||
db.commit()
|
||||
|
||||
profile = SshProfile(
|
||||
owner_id=user.id,
|
||||
name="Test box",
|
||||
host="127.0.0.1",
|
||||
port=22,
|
||||
username="tester",
|
||||
host_key="host key",
|
||||
host_fingerprint="SHA256:x",
|
||||
default_dir="/project",
|
||||
)
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
|
||||
chat = Chat(
|
||||
user_id=user.id,
|
||||
model_id="m",
|
||||
connection_id=connection.id,
|
||||
kind=KIND_AGENT,
|
||||
ssh_profile_id=profile.id,
|
||||
project_dir="/project",
|
||||
agent_mode=mode,
|
||||
)
|
||||
db.add(chat)
|
||||
db.commit()
|
||||
return chat
|
||||
|
||||
|
||||
def test_patching_the_mode_changes_it(client: TestClient, db, registered):
|
||||
chat = _agent_chat(db)
|
||||
|
||||
response = client.patch(f"/api/chats/{chat.id}", data={"agent_mode": policy.MODE_PLAN})
|
||||
assert response.status_code == 204, response.text
|
||||
|
||||
db.refresh(chat)
|
||||
assert chat.agent_mode == policy.MODE_PLAN
|
||||
|
||||
|
||||
def test_the_mode_form_uses_a_method_the_route_serves(client: TestClient, db, registered):
|
||||
"""The bug itself, stated as a test rather than as a comment.
|
||||
|
||||
POST is not merely unhandled here, it is *silently* unhandled: htmx swallows
|
||||
the 405 and the select keeps showing whatever was clicked. Asserting the
|
||||
method is refused is what stops somebody reintroducing `hx-post` and finding
|
||||
the mode unchangeable again with nothing in the logs.
|
||||
"""
|
||||
chat = _agent_chat(db)
|
||||
refused = client.post(f"/api/chats/{chat.id}", data={"agent_mode": policy.MODE_AUTO})
|
||||
assert refused.status_code == 405
|
||||
db.refresh(chat)
|
||||
assert chat.agent_mode == policy.MODE_MANUAL
|
||||
|
||||
|
||||
def test_the_rendered_form_patches(client: TestClient, db, registered):
|
||||
"""And that the template actually carries it, since that is where it broke.
|
||||
|
||||
Pinned to the mode form by its id rather than to any `hx-patch` on the
|
||||
page: the model picker and the system-prompt box patch the same URL, so a
|
||||
looser assertion would have passed throughout the entire life of the bug.
|
||||
"""
|
||||
chat = _agent_chat(db)
|
||||
body = client.get(f"/chat/{chat.id}").text
|
||||
|
||||
assert 'id="agent-mode-form"' in body
|
||||
form = body[body.index('id="agent-mode-form"') :][:200]
|
||||
assert f'hx-patch="/api/chats/{chat.id}"' in form
|
||||
assert "hx-post" not in form
|
||||
# And that the select outside it is actually submitted by it.
|
||||
assert 'form="agent-mode-form"' in body
|
||||
|
||||
|
||||
def test_the_mode_is_offered_beside_the_composer_not_in_the_topbar(
|
||||
client: TestClient, db, registered
|
||||
):
|
||||
"""Where it is matters: it belongs where the message is written.
|
||||
|
||||
Asserted on order rather than on a class name so it survives a restyle --
|
||||
what is being pinned is that the control comes after the thread, not what
|
||||
it looks like.
|
||||
"""
|
||||
chat = _agent_chat(db)
|
||||
body = client.get(f"/chat/{chat.id}").text
|
||||
|
||||
assert body.index('id="thread"') < body.index('id="agent-mode-form"')
|
||||
|
||||
|
||||
# --- Chosen before the first word --------------------------------------------
|
||||
def _profile_for(db) -> SshProfile:
|
||||
"""A usable connection, and the feature switched on, with no chat yet."""
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
|
||||
user = db.scalars(select(User)).first()
|
||||
assert user is not None
|
||||
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
db.add(Model(connection_id=connection.id, model_id="m"))
|
||||
profile = SshProfile(
|
||||
owner_id=user.id,
|
||||
name="Test box",
|
||||
host="127.0.0.1",
|
||||
port=22,
|
||||
username="tester",
|
||||
host_key="host key",
|
||||
host_fingerprint="SHA256:x",
|
||||
default_dir="/project",
|
||||
)
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
return profile
|
||||
|
||||
|
||||
def test_a_chat_can_start_in_plan_mode(client: TestClient, db, registered):
|
||||
"""The whole point: reaching Plan without first sending something in Manual."""
|
||||
profile = _profile_for(db)
|
||||
|
||||
client.post(
|
||||
"/api/chats/start",
|
||||
data={
|
||||
"content": "have a look around",
|
||||
"kind": "agent",
|
||||
"ssh_profile_id": profile.id,
|
||||
"project_dir": "/project",
|
||||
"agent_mode": policy.MODE_PLAN,
|
||||
},
|
||||
)
|
||||
|
||||
chat = db.scalars(select(Chat)).one()
|
||||
assert chat.kind == KIND_AGENT
|
||||
assert chat.agent_mode == policy.MODE_PLAN
|
||||
|
||||
|
||||
def test_a_chat_started_with_no_mode_is_manual(client: TestClient, db, registered):
|
||||
"""The column default still governs, so nobody's habits change."""
|
||||
profile = _profile_for(db)
|
||||
|
||||
client.post(
|
||||
"/api/chats/start",
|
||||
data={"content": "hello", "kind": "agent", "ssh_profile_id": profile.id},
|
||||
)
|
||||
|
||||
assert db.scalars(select(Chat)).one().agent_mode == policy.MODE_MANUAL
|
||||
|
||||
|
||||
def test_an_unrecognised_mode_at_creation_is_ignored(client: TestClient, db, registered):
|
||||
profile = _profile_for(db)
|
||||
|
||||
client.post(
|
||||
"/api/chats/start",
|
||||
data={
|
||||
"content": "hello",
|
||||
"kind": "agent",
|
||||
"ssh_profile_id": profile.id,
|
||||
"agent_mode": "root",
|
||||
},
|
||||
)
|
||||
|
||||
assert db.scalars(select(Chat)).one().agent_mode == policy.MODE_MANUAL
|
||||
|
||||
|
||||
def test_a_mode_on_a_plain_chat_is_ignored(client: TestClient, db, registered):
|
||||
"""A plain chat has no approval loop, so a mode on one means nothing.
|
||||
|
||||
It must not silently become an agent chat either: the connection is what
|
||||
decides that, and there is none here.
|
||||
"""
|
||||
_profile_for(db)
|
||||
|
||||
client.post("/api/chats/start", data={"content": "hello", "agent_mode": policy.MODE_AUTO})
|
||||
|
||||
chat = db.scalars(select(Chat)).one()
|
||||
assert chat.kind != KIND_AGENT
|
||||
assert chat.agent_mode == policy.MODE_MANUAL
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wanted", ["", "sudo", "PLAN", "edit;auto"])
|
||||
def test_an_unrecognised_mode_is_ignored(client: TestClient, db, registered, wanted):
|
||||
"""Ignored rather than refused: an unknown mode is a bug in the sender, and
|
||||
failing the whole request would leave the reader with a chat they cannot
|
||||
change. The safe outcome is that nothing moves."""
|
||||
chat = _agent_chat(db, mode=policy.MODE_EDIT)
|
||||
|
||||
client.patch(f"/api/chats/{chat.id}", data={"agent_mode": wanted})
|
||||
|
||||
db.refresh(chat)
|
||||
assert chat.agent_mode == policy.MODE_EDIT
|
||||
Reference in New Issue
Block a user