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