0bee366488
composer.js built its menu lazily inside show(), and refresh() wrote list.innerHTML before calling it. `list` is null until build() has run, so the first `/` or `@` ever typed threw a TypeError and took the handler with it. The menu has never appeared in any browser. That is why /compact "isn't there": nothing was. I shipped it having only run `node --check`, which parses the file happily. So this also brings the thing that catches it: a DOM stub driven under node -- not committed, hard rule 1 stands, it is an instrument like curl. It reproduced the crash in one run and immediately found two more: choosing a command from the menu left `/help` sitting in the box so the next Enter ran it again, and Tab completed nothing. Tab now completes and Enter runs, which is the split that matters for a command taking an argument. `.select--sm` was used three times and defined nowhere. I deleted the copy in chat.css and left a comment saying it "is defined once, in app.css", where it did not exist -- so those selects fell back to plain `.select`: width 100% in a flex row where four siblings wanted the same, all of them shrinking together until each was a few characters wide, and half a rem taller than everything beside them. That was the whole of "the connection switch needs to be wider". The connection and directory move to the topbar. They cannot change -- update_chat refuses both with a 409 -- so they are facts about the chat, of a kind with the Temporary badge, not controls on the message. The mode stays by the box. Compaction says it is working. It makes a model call that takes seconds and had no indicator anywhere: `hx-indicator` appears nowhere in this codebase, and the Generation.status channel that says "Summarising earlier messages…" for the automatic path cannot be borrowed, because it lives in the streaming bubble and this endpoint refuses to run while any message is unfinished. The overflow menu now runs the same code as /compact rather than posting for itself, so there is one implementation, one spinner, and one place the endpoint's four carefully written 409s finally reach somebody. /effort, low medium high, per chat with a per-model default. It goes out twice because there is no field that works everywhere: OpenAI and vLLM read reasoning_effort, llama.cpp's own docs say other values "have no effect" and its maintainer says the field "simply gets dropped without error or logging" -- what reaches gpt-oss behind it is chat_template_kwargs. Both are sent, and only once an effort has been chosen, so a provider strict about unknown parameters sees exactly the request it always did until somebody opts in. The control appears only on a model marked `reasoning`, a flag that has existed since the beginning with no reader at all. Mentions and recognised commands are marked as you type -- a mirror behind the textarea holding the same text with every character transparent, contributing nothing but a rounded rectangle, so a pixel of drift is a misplaced rectangle rather than a doubled glyph. A command is marked only when it resolves, so `/thoughts on this` visibly is not one before you send it. And again in the transcript, where user turns had no render step at all and now escape before they inject. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1155 lines
34 KiB
CSS
1155 lines
34 KiB
CSS
/*
|
|
Chat thread, composer, message bodies and code blocks.
|
|
|
|
Loaded only on chat pages. Like app.css, every value resolves through
|
|
tokens.css.
|
|
*/
|
|
|
|
/* --- Thread --------------------------------------------------------------- */
|
|
.thread-scroll {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
scroll-behavior: smooth;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: var(--border-strong) transparent;
|
|
}
|
|
|
|
.thread {
|
|
max-width: var(--thread-max-width);
|
|
margin: 0 auto;
|
|
padding: var(--sp-6) var(--sp-5) var(--sp-8);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--sp-6);
|
|
}
|
|
|
|
.thread__intro {
|
|
display: grid;
|
|
place-items: center;
|
|
gap: var(--sp-3);
|
|
text-align: center;
|
|
padding: var(--sp-12) 0 var(--sp-6);
|
|
}
|
|
|
|
/* --- Messages ------------------------------------------------------------- */
|
|
.msg {
|
|
display: grid;
|
|
grid-template-columns: 2rem 1fr;
|
|
gap: var(--sp-3);
|
|
align-items: start;
|
|
}
|
|
|
|
.msg__gutter {
|
|
display: grid;
|
|
place-items: center;
|
|
width: 2rem;
|
|
height: 2rem;
|
|
border-radius: var(--radius-full);
|
|
overflow: hidden;
|
|
}
|
|
.msg__mark { width: 2rem; height: 2rem; }
|
|
.msg__avatar { width: 2rem; height: 2rem; border-radius: var(--radius); }
|
|
.msg__initial {
|
|
width: 2rem; height: 2rem;
|
|
display: grid;
|
|
place-items: center;
|
|
border-radius: var(--radius-full);
|
|
background: var(--surface-active);
|
|
color: var(--ink-muted);
|
|
font-size: var(--text-sm);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.msg__main { min-width: 0; }
|
|
|
|
.msg__meta {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: var(--sp-2);
|
|
margin-bottom: var(--sp-1);
|
|
}
|
|
.msg__author {
|
|
font-family: var(--font-display);
|
|
font-size: var(--text-base);
|
|
font-weight: 600;
|
|
}
|
|
.msg__model {
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
font-family: var(--font-mono);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-width: 16rem;
|
|
}
|
|
|
|
.msg__body {
|
|
line-height: var(--leading-relaxed);
|
|
overflow-wrap: break-word;
|
|
}
|
|
/* User turns and mid-stream assistant text are plain text, so newlines and
|
|
runs of spaces have to survive. */
|
|
.msg__body--plain { white-space: pre-wrap; }
|
|
|
|
.msg--user .msg__body--plain {
|
|
background: var(--bubble-user);
|
|
padding: var(--sp-3) var(--sp-4);
|
|
border-radius: var(--radius-lg);
|
|
display: inline-block;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.msg__error { margin: var(--sp-2) 0; align-items: flex-start; }
|
|
|
|
/* --- Streaming indicator -------------------------------------------------- */
|
|
/* Shown until the first token arrives, then hidden by the sibling selector
|
|
below -- no JavaScript involved in either direction. */
|
|
.msg__waiting { padding: var(--sp-2) 0; }
|
|
|
|
.dots { display: inline-flex; gap: 0.25rem; align-items: center; }
|
|
.dots i {
|
|
width: 0.4rem;
|
|
height: 0.4rem;
|
|
border-radius: var(--radius-full);
|
|
background: var(--ink-faint);
|
|
animation: dot-pulse 1.3s ease-in-out infinite;
|
|
}
|
|
.dots i:nth-child(2) { animation-delay: 0.18s; }
|
|
.dots i:nth-child(3) { animation-delay: 0.36s; }
|
|
|
|
@keyframes dot-pulse {
|
|
0%, 60%, 100% { opacity: 0.28; transform: translateY(0); }
|
|
30% { opacity: 1; transform: translateY(-2px); }
|
|
}
|
|
|
|
/* A caret trailing the text while it streams. Attached to the last block so it
|
|
sits at the end of the prose rather than on a line of its own. */
|
|
.msg__body--live:not(:empty) > :last-child::after {
|
|
content: "";
|
|
display: inline-block;
|
|
width: 0.45rem;
|
|
height: 1.05em;
|
|
margin-left: 1px;
|
|
vertical-align: text-bottom;
|
|
background: var(--leaf);
|
|
opacity: 0.75;
|
|
animation: caret 1.05s steps(1) infinite;
|
|
}
|
|
@keyframes caret { 0%, 49% { opacity: 0.75; } 50%, 100% { opacity: 0; } }
|
|
|
|
/* --- Reasoning ------------------------------------------------------------ */
|
|
.reasoning {
|
|
margin: 0 0 var(--sp-3);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
background: color-mix(in srgb, var(--surface) 70%, transparent);
|
|
font-size: var(--text-sm);
|
|
}
|
|
|
|
/*
|
|
A live block is emitted before the first token arrives, and plenty of models
|
|
emit no reasoning at all. Hiding it until it has content means those models
|
|
never show an empty "Thinking" box, and no JavaScript is involved either way.
|
|
*/
|
|
.reasoning--live:not(:has(.reasoning__body:not(:empty))) { display: none; }
|
|
|
|
.reasoning__summary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
padding: var(--sp-2) var(--sp-3);
|
|
cursor: pointer;
|
|
color: var(--ink-muted);
|
|
list-style: none;
|
|
user-select: none;
|
|
border-radius: var(--radius);
|
|
}
|
|
.reasoning__summary::-webkit-details-marker { display: none; }
|
|
.reasoning__summary:hover { color: var(--ink); background: var(--surface-hover); }
|
|
|
|
/* --- Compacted turns -------------------------------------------------------
|
|
Summarised messages, kept and readable but out of the way. Collapsed by
|
|
default: the point of compacting was that they stopped mattering.
|
|
*/
|
|
.compacted {
|
|
border: 1px dashed var(--border-strong);
|
|
border-radius: var(--radius);
|
|
background: color-mix(in srgb, var(--surface) 50%, transparent);
|
|
}
|
|
.compacted__summary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
padding: var(--sp-2) var(--sp-3);
|
|
cursor: pointer;
|
|
color: var(--ink-muted);
|
|
font-size: var(--text-sm);
|
|
list-style: none;
|
|
user-select: none;
|
|
}
|
|
.compacted__summary::-webkit-details-marker { display: none; }
|
|
.compacted__summary:hover { color: var(--ink); }
|
|
.compacted[open] .reasoning__chevron { transform: rotate(180deg); }
|
|
|
|
.compacted__note {
|
|
margin: 0;
|
|
padding: 0 var(--sp-3);
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
line-height: var(--leading-normal);
|
|
}
|
|
.compacted__body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--sp-6);
|
|
padding: var(--sp-4) var(--sp-3);
|
|
opacity: 0.75;
|
|
}
|
|
|
|
/* --- Suggestions -----------------------------------------------------------
|
|
Starting points on the empty screen. Cards rather than a list, because they
|
|
are things to press.
|
|
*/
|
|
.suggestions {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
|
gap: var(--sp-3);
|
|
width: 100%;
|
|
max-width: 40rem;
|
|
margin-top: var(--sp-6);
|
|
text-align: left;
|
|
}
|
|
|
|
.suggestion {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--sp-1);
|
|
padding: var(--sp-3) var(--sp-4);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
background: var(--surface);
|
|
color: var(--ink);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: background var(--transition-fast), border-color var(--transition-fast);
|
|
}
|
|
.suggestion:hover { background: var(--surface-hover); border-color: var(--border-strong); }
|
|
.suggestion:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
|
|
.suggestion__name { font-weight: 600; font-size: var(--text-sm); }
|
|
.suggestion__note {
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-muted);
|
|
line-height: var(--leading-normal);
|
|
}
|
|
|
|
/* Something is happening to the whole thread rather than to one message --
|
|
compaction, which makes a model call and can take a while. Shaped like a
|
|
turn so it sits in the column rather than floating over it. */
|
|
.thread__working {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-3);
|
|
padding: var(--sp-3) 0;
|
|
color: var(--ink-muted);
|
|
font-size: var(--text-sm);
|
|
}
|
|
|
|
/* --- Metrics ---------------------------------------------------------------
|
|
What a reply cost, under the bubble. Quiet by default: it is reference, not
|
|
something to read every time.
|
|
*/
|
|
.msg__metrics {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: var(--sp-3);
|
|
margin-top: var(--sp-2);
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.msg__metrics:empty { display: none; }
|
|
|
|
.metric { display: inline-flex; align-items: center; gap: var(--sp-1); cursor: default; }
|
|
|
|
.metric__bar {
|
|
display: inline-block;
|
|
width: 3rem;
|
|
height: 0.3rem;
|
|
border-radius: var(--radius-full);
|
|
background: var(--surface-active);
|
|
overflow: hidden;
|
|
}
|
|
.metric__fill {
|
|
display: block;
|
|
height: 100%;
|
|
background: var(--ink-faint);
|
|
transition: width var(--transition);
|
|
}
|
|
.metric--context.is-warning { color: var(--warning); }
|
|
.metric--context.is-warning .metric__fill { background: var(--warning); }
|
|
.metric--context.is-danger { color: var(--danger); }
|
|
.metric--context.is-danger .metric__fill { background: var(--danger); }
|
|
|
|
.reasoning__icon { color: var(--leaf); flex: none; }
|
|
.reasoning__label { flex: 1; font-style: italic; }
|
|
|
|
.reasoning__chevron {
|
|
flex: none;
|
|
transition: transform var(--transition-fast);
|
|
}
|
|
.reasoning[open] .reasoning__chevron { transform: rotate(180deg); }
|
|
|
|
.reasoning__body {
|
|
padding: 0 var(--sp-3) var(--sp-3);
|
|
margin-left: var(--sp-2);
|
|
border-left: 2px solid var(--border-strong);
|
|
padding-left: var(--sp-3);
|
|
white-space: pre-wrap;
|
|
color: var(--ink-muted);
|
|
font-size: var(--text-sm);
|
|
line-height: var(--leading-relaxed);
|
|
max-height: 26rem;
|
|
overflow-y: auto;
|
|
scrollbar-width: thin;
|
|
}
|
|
|
|
/* Gentle pulse on the icon while thinking is still streaming. */
|
|
.reasoning--live .reasoning__icon { animation: think-pulse 1.6s ease-in-out infinite; }
|
|
@keyframes think-pulse {
|
|
0%, 100% { opacity: 0.45; }
|
|
50% { opacity: 1; }
|
|
}
|
|
|
|
/* --- Tool activity --------------------------------------------------------
|
|
Deliberately the same object as the reasoning block: both answer "what did
|
|
it do before it replied", and giving them two visual languages would suggest
|
|
a difference that is not there. */
|
|
.tool-activity-list:empty { display: none; }
|
|
|
|
.tool-activity {
|
|
margin: 0 0 var(--sp-3);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
background: color-mix(in srgb, var(--surface) 70%, transparent);
|
|
font-size: var(--text-sm);
|
|
}
|
|
.tool-activity--error { border-color: var(--danger); }
|
|
|
|
.tool-activity__summary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
padding: var(--sp-2) var(--sp-3);
|
|
cursor: pointer;
|
|
color: var(--ink-muted);
|
|
list-style: none;
|
|
user-select: none;
|
|
border-radius: var(--radius);
|
|
}
|
|
.tool-activity__summary::-webkit-details-marker { display: none; }
|
|
.tool-activity__summary:hover { color: var(--ink); background: var(--surface-hover); }
|
|
.tool-activity__icon { color: var(--leaf); flex: none; }
|
|
.tool-activity__label { flex: 1; }
|
|
.tool-activity__count { color: var(--ink-faint); }
|
|
.tool-activity[open] .reasoning__chevron { transform: rotate(180deg); }
|
|
|
|
.tool-activity__body {
|
|
padding: 0 var(--sp-3) var(--sp-3);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--sp-3);
|
|
}
|
|
.tool-activity__error { margin: 0; color: var(--ink-muted); }
|
|
|
|
.tool-result {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
padding-left: var(--sp-3);
|
|
border-left: 2px solid var(--border-strong);
|
|
min-width: 0;
|
|
}
|
|
.tool-result__title {
|
|
color: var(--accent);
|
|
font-weight: 500;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.tool-result__host { color: var(--ink-faint); font-size: var(--text-xs); }
|
|
.tool-result__snippet {
|
|
margin: 0;
|
|
color: var(--ink-muted);
|
|
line-height: var(--leading-relaxed);
|
|
}
|
|
|
|
/* What a tool returned, verbatim. Preformatted rather than rendered: this is
|
|
third-party text and markdown is the one path allowed to emit HTML. */
|
|
.tool-result__text {
|
|
margin: 0;
|
|
padding: var(--sp-3);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--bg-sunken);
|
|
color: var(--ink-muted);
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-xs);
|
|
line-height: var(--leading-relaxed);
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
max-height: 22em;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* --- The model asking you something --------------------------------------- */
|
|
/* Attributed to the model on purpose. A card styled like the application is a
|
|
card people answer with things they would not tell a chatbot. */
|
|
.interaction {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--sp-3);
|
|
margin: var(--sp-3) 0;
|
|
padding: var(--sp-4);
|
|
border: 1px solid var(--accent);
|
|
border-radius: var(--radius-md);
|
|
background: var(--surface);
|
|
}
|
|
.interaction__from {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
margin: 0;
|
|
color: var(--ink-faint);
|
|
font-size: var(--text-xs);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
}
|
|
.interaction__form { display: flex; flex-direction: column; gap: var(--sp-4); }
|
|
/* One block per question. Several go on one card and submit together. */
|
|
.interaction__question {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--sp-2);
|
|
margin: 0;
|
|
padding: 0;
|
|
border: 0;
|
|
min-width: 0;
|
|
}
|
|
.interaction__question + .interaction__question {
|
|
padding-top: var(--sp-4);
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
.interaction__title { margin: 0; padding: 0; color: var(--ink); font-weight: 500; }
|
|
.interaction__options { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
|
.interaction__note { color: var(--ink-faint); font-size: var(--text-xs); }
|
|
|
|
/* An option. A radio, so picking one unpicks the last, but shaped like the
|
|
button it reads as. */
|
|
.chip { position: relative; display: inline-flex; }
|
|
.chip input {
|
|
position: absolute;
|
|
inset: 0;
|
|
opacity: 0;
|
|
cursor: pointer;
|
|
margin: 0;
|
|
}
|
|
.chip span {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
min-height: var(--control-h);
|
|
padding: 0 var(--sp-3);
|
|
border: 1px solid var(--border-strong);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--surface-raised);
|
|
color: var(--ink-muted);
|
|
font-size: var(--text-sm);
|
|
}
|
|
.chip input:hover + span { background: var(--surface-hover); color: var(--ink); }
|
|
.chip input:checked + span {
|
|
border-color: var(--accent);
|
|
background: var(--surface-active);
|
|
color: var(--ink);
|
|
}
|
|
.chip input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
.interaction__detail {
|
|
margin: 0;
|
|
padding: var(--sp-3);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--bg-sunken);
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-xs);
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.interaction__reason { margin: 0; color: var(--ink-muted); font-size: var(--text-xs); }
|
|
.interaction__actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--sp-2);
|
|
align-items: center;
|
|
}
|
|
.interaction__write { display: flex; gap: var(--sp-2); flex: 1 1 16rem; min-width: 0; }
|
|
.interaction__write .input { flex: 1; min-width: 0; }
|
|
|
|
/* --- Stop, notes and editing ---------------------------------------------- */
|
|
.msg__status { font-size: var(--text-xs); color: var(--ink-faint); font-style: italic; }
|
|
.msg__status:empty { display: none; }
|
|
|
|
.msg__waiting {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-3);
|
|
padding: var(--sp-2) 0;
|
|
}
|
|
/* Once the answer has content the caret carries the "still going" signal, so
|
|
the dots go, but Stop must stay reachable until the stream ends. */
|
|
.msg__body--live:not(:empty) + .msg__waiting .dots { display: none; }
|
|
|
|
/* Send and Stop are one button. Which icon shows is decided here rather than
|
|
in JavaScript, so the state is visible in the markup and the swap is free. */
|
|
.composer__icon { display: flex; }
|
|
[data-composer-action="send"] .composer__icon--stop,
|
|
[data-composer-action="stop"] .composer__icon--send { display: none; }
|
|
|
|
.composer__btn[data-composer-action="stop"] {
|
|
background: var(--danger);
|
|
border-color: var(--danger);
|
|
color: var(--ink-inverse);
|
|
}
|
|
.composer__btn[data-composer-action="stop"]:hover:not(:disabled) {
|
|
background: var(--danger-hover);
|
|
border-color: var(--danger-hover);
|
|
}
|
|
/* The microphone is the same shape: one button, state in a data attribute. */
|
|
[data-mic-state="idle"] .composer__icon--recording,
|
|
[data-mic-state="working"] .composer__icon--recording,
|
|
[data-mic-state="recording"] .composer__icon--mic { display: none; }
|
|
|
|
.composer__mic[data-mic-state="recording"] {
|
|
background: var(--danger);
|
|
border-color: var(--danger);
|
|
color: var(--ink-inverse);
|
|
animation: mic-pulse 1.6s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes mic-pulse {
|
|
0%, 100% { box-shadow: 0 0 0 0 var(--accent-soft); }
|
|
50% { box-shadow: 0 0 0 5px transparent; }
|
|
}
|
|
|
|
/* Play and stop on the read-aloud button, chosen by the class audio.js sets. */
|
|
.speak__icon { display: flex; }
|
|
[data-speak] .speak__icon--stop,
|
|
[data-speak].is-speaking .speak__icon--play { display: none; }
|
|
[data-speak].is-speaking .speak__icon--stop { display: flex; }
|
|
[data-speak].is-speaking { color: var(--accent); }
|
|
|
|
.composer__stop-square {
|
|
width: 0.7rem;
|
|
height: 0.7rem;
|
|
border-radius: 2px;
|
|
background: currentColor;
|
|
}
|
|
|
|
.msg__note {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-1);
|
|
margin: var(--sp-2) 0 0;
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
font-style: italic;
|
|
}
|
|
|
|
.msg--editing .msg__main { width: 100%; }
|
|
.edit-form { display: flex; flex-direction: column; gap: var(--sp-3); }
|
|
.edit-form .textarea { min-height: 4rem; }
|
|
|
|
/* --- Message actions ------------------------------------------------------ */
|
|
.msg__actions {
|
|
display: flex;
|
|
gap: var(--sp-1);
|
|
margin-top: var(--sp-2);
|
|
opacity: 0;
|
|
transition: opacity var(--transition-fast);
|
|
}
|
|
.msg:hover .msg__actions,
|
|
.msg:focus-within .msg__actions { opacity: 1; }
|
|
.msg__actions .is-copied { color: var(--success); }
|
|
|
|
/* --- Rendered Markdown ---------------------------------------------------- */
|
|
.msg__body > :first-child { margin-top: 0; }
|
|
.msg__body > :last-child { margin-bottom: 0; }
|
|
|
|
.msg__body h1, .msg__body h2, .msg__body h3,
|
|
.msg__body h4, .msg__body h5, .msg__body h6 {
|
|
margin: var(--sp-5) 0 var(--sp-2);
|
|
}
|
|
.msg__body h1 { font-size: var(--text-xl); }
|
|
.msg__body h2 { font-size: var(--text-lg); }
|
|
.msg__body h3 { font-size: var(--text-md); }
|
|
|
|
.msg__body ul, .msg__body ol { margin: 0 0 var(--sp-4); padding-left: var(--sp-6); }
|
|
.msg__body li { margin-bottom: var(--sp-1); }
|
|
|
|
.msg__body blockquote {
|
|
margin: 0 0 var(--sp-4);
|
|
padding: var(--sp-1) var(--sp-4);
|
|
border-left: 3px solid var(--border-strong);
|
|
color: var(--ink-muted);
|
|
font-style: italic;
|
|
}
|
|
|
|
.msg__body hr { border: 0; border-top: 1px solid var(--border); margin: var(--sp-5) 0; }
|
|
|
|
.msg__body :not(pre) > code {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.875em;
|
|
padding: 0.13em 0.36em;
|
|
border-radius: var(--radius-sm);
|
|
background: var(--code-bg);
|
|
border: 1px solid var(--code-border);
|
|
}
|
|
|
|
.msg__body table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin: 0 0 var(--sp-4);
|
|
font-size: var(--text-sm);
|
|
display: block;
|
|
overflow-x: auto;
|
|
}
|
|
.msg__body th, .msg__body td {
|
|
border: 1px solid var(--border);
|
|
padding: var(--sp-2) var(--sp-3);
|
|
text-align: left;
|
|
}
|
|
.msg__body th { background: var(--surface); font-weight: 600; }
|
|
|
|
.msg__body img { max-width: 100%; height: auto; border-radius: var(--radius); }
|
|
|
|
/* --- Code blocks ---------------------------------------------------------- */
|
|
.code-block {
|
|
margin: 0 0 var(--sp-4);
|
|
border: 1px solid var(--code-border);
|
|
border-radius: var(--radius);
|
|
background: var(--code-bg);
|
|
overflow: hidden;
|
|
}
|
|
.code-block__label {
|
|
padding: var(--sp-1) var(--sp-3);
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
border-bottom: 1px solid var(--code-border);
|
|
background: color-mix(in srgb, var(--code-bg) 60%, var(--surface));
|
|
}
|
|
.code-block__pre {
|
|
margin: 0;
|
|
padding: var(--sp-3) var(--sp-4);
|
|
overflow-x: auto;
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-sm);
|
|
line-height: 1.55;
|
|
}
|
|
.code-block__pre code { font-family: inherit; background: none; border: 0; padding: 0; }
|
|
|
|
/*
|
|
Pygments token colours, mapped onto theme tokens rather than a fixed scheme,
|
|
so code follows the active theme. classprefix "pg-" is set in markdown.py.
|
|
*/
|
|
.pg-c, .pg-c1, .pg-cm, .pg-cs, .pg-cp { color: var(--ink-faint); font-style: italic; }
|
|
/* Keywords take --warning, not the brand accent: strings are already green, and
|
|
two greens in one code block is not a colour scheme, it is a bug report. */
|
|
.pg-k, .pg-kn, .pg-kd, .pg-kc, .pg-kr, .pg-kt { color: var(--warning); }
|
|
.pg-s, .pg-s1, .pg-s2, .pg-sb, .pg-sd, .pg-se, .pg-sh, .pg-si, .pg-sx { color: var(--success); }
|
|
.pg-m, .pg-mi, .pg-mf, .pg-mh, .pg-mo { color: var(--danger); }
|
|
.pg-nf, .pg-nd { color: var(--accent); }
|
|
.pg-nc, .pg-nn { color: var(--accent-hover); font-weight: 600; }
|
|
.pg-nb, .pg-bp { color: var(--accent); }
|
|
.pg-nv, .pg-vi, .pg-vg, .pg-vc { color: var(--ink); }
|
|
.pg-o, .pg-ow, .pg-p { color: var(--ink-muted); }
|
|
.pg-err { color: var(--danger); }
|
|
.pg-gd { color: var(--danger); }
|
|
.pg-gi { color: var(--success); }
|
|
|
|
/* --- Composer ------------------------------------------------------------- */
|
|
.composer {
|
|
flex: none;
|
|
padding: var(--sp-3) var(--sp-5) var(--sp-4);
|
|
border-top: 1px solid var(--border);
|
|
background: var(--bg);
|
|
}
|
|
/* position: relative anchors the `@` and `/` menu to the box. */
|
|
.composer__inner {
|
|
position: relative;
|
|
max-width: var(--thread-max-width);
|
|
margin: 0 auto;
|
|
}
|
|
|
|
/*
|
|
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-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__form:focus-within {
|
|
border-color: var(--accent);
|
|
box-shadow: var(--ring);
|
|
}
|
|
|
|
/*
|
|
The text, and the mirror behind it.
|
|
|
|
Every property that affects where a character lands is declared once, on both
|
|
-- font, size, line height, padding, wrapping. A single difference and the
|
|
rectangles slide off the words they are marking. `font: inherit` on the
|
|
textarea is not enough: a textarea's default font is not the page's.
|
|
*/
|
|
.composer__field { position: relative; }
|
|
|
|
.composer__input,
|
|
.composer__mirror {
|
|
width: 100%;
|
|
min-width: 0;
|
|
margin: 0;
|
|
padding: var(--sp-2) var(--sp-2) var(--sp-1);
|
|
border: 0;
|
|
font-family: var(--font-body);
|
|
font-size: var(--text-base);
|
|
line-height: var(--leading-normal);
|
|
letter-spacing: normal;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: break-word;
|
|
word-break: normal;
|
|
max-height: 20rem;
|
|
}
|
|
|
|
.composer__input {
|
|
position: relative;
|
|
display: block;
|
|
background: none;
|
|
resize: none;
|
|
color: var(--ink);
|
|
overflow-y: auto;
|
|
}
|
|
.composer__input:focus { outline: none; }
|
|
|
|
/* Behind, and contributing nothing but background rectangles: its own text is
|
|
transparent, so the glyphs on screen are always the textarea's. That is what
|
|
makes a pixel of drift invisible rather than a doubled letter. */
|
|
.composer__mirror {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 0;
|
|
overflow: hidden;
|
|
color: transparent;
|
|
pointer-events: none;
|
|
user-select: none;
|
|
}
|
|
.composer__mirror .tok-mention,
|
|
.composer__mirror .tok-command {
|
|
border-radius: var(--radius-sm);
|
|
/* Bled sideways so the rectangle does not sit hard against the next word,
|
|
and the negative margin keeps the text metrics identical. */
|
|
padding: 0 2px;
|
|
margin: 0 -2px;
|
|
}
|
|
.composer__mirror .tok-mention { background: var(--accent-soft); }
|
|
.composer__mirror .tok-command { background: var(--leaf-soft); }
|
|
|
|
/* The same two in a sent message, where they are text rather than a backdrop. */
|
|
.tok-mention {
|
|
border-radius: var(--radius-sm);
|
|
padding: 0 2px;
|
|
background: var(--accent-soft);
|
|
color: var(--accent);
|
|
font-family: var(--font-mono);
|
|
font-size: 0.95em;
|
|
}
|
|
|
|
/* 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; }
|
|
|
|
.composer__hint {
|
|
margin: var(--sp-2) 0 0;
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
text-align: center;
|
|
}
|
|
.composer__hint-link {
|
|
border: 0;
|
|
padding: 0;
|
|
background: none;
|
|
font: inherit;
|
|
color: var(--ink-muted);
|
|
text-decoration: underline dotted;
|
|
text-underline-offset: 2px;
|
|
cursor: pointer;
|
|
}
|
|
.composer__hint-link:hover { color: var(--accent); }
|
|
|
|
/*
|
|
The menu `@` and `/` open.
|
|
|
|
Above the composer, not below it: the composer is already at the bottom of
|
|
the window, so anything dropping down would be off screen -- the same reason
|
|
the attach menu is `picker--up`. Anchored to .composer__inner so it lines up
|
|
with the box rather than with the caret; a caret-following menu is nicer and
|
|
needs measuring text in a textarea, which cannot be done without a hidden
|
|
mirror element.
|
|
*/
|
|
.composer-menu {
|
|
position: absolute;
|
|
bottom: calc(100% + var(--sp-2));
|
|
left: 0;
|
|
right: 0;
|
|
z-index: var(--z-dropdown);
|
|
max-height: min(20rem, 45vh);
|
|
overflow-y: auto;
|
|
scrollbar-width: thin;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
background: var(--surface-raised);
|
|
box-shadow: var(--shadow-lg);
|
|
}
|
|
|
|
/* What the keys do, pinned to the bottom of the menu. */
|
|
.composer-menu__keys {
|
|
position: sticky;
|
|
bottom: 0;
|
|
padding: var(--sp-1) var(--sp-3);
|
|
border-top: 1px solid var(--border);
|
|
background: var(--surface-raised);
|
|
color: var(--ink-faint);
|
|
font-size: var(--text-xs);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.picker__group {
|
|
margin: 0;
|
|
padding: var(--sp-2) var(--sp-3) var(--sp-1);
|
|
font-size: var(--text-xs);
|
|
font-weight: 600;
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
color: var(--ink-faint);
|
|
}
|
|
|
|
/* The help and usage sheets. */
|
|
.sheet { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
|
.sheet td { padding: var(--sp-1) var(--sp-2); vertical-align: top; }
|
|
.sheet td:first-child { white-space: nowrap; color: var(--ink-muted); width: 1%; }
|
|
.sheet tr + tr td { border-top: 1px solid var(--border); }
|
|
|
|
/* --- Folders -------------------------------------------------------------- */
|
|
.folder__row { padding-right: var(--sp-1); }
|
|
.folder__toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
flex: 1;
|
|
min-width: 0;
|
|
background: none;
|
|
border: 0;
|
|
padding: 0;
|
|
color: inherit;
|
|
font: inherit;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
.folder__chevron {
|
|
display: inline-flex;
|
|
transition: transform var(--transition-fast);
|
|
}
|
|
.folder__chevron.is-open { transform: rotate(90deg); }
|
|
.folder__contents { padding-left: var(--sp-4); }
|
|
|
|
.nav-item__link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
flex: 1;
|
|
min-width: 0;
|
|
color: inherit;
|
|
text-decoration: none;
|
|
}
|
|
|
|
/* --- 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 {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--sp-2);
|
|
padding: var(--sp-1) var(--sp-1) 0;
|
|
}
|
|
.composer__attachments:empty { display: none; }
|
|
|
|
.attach-chip {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
padding: var(--sp-2);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
background: var(--surface);
|
|
max-width: 20rem;
|
|
font-size: var(--text-sm);
|
|
}
|
|
.attach-chip--error { border-color: var(--danger); background: var(--danger-soft); }
|
|
.attach-chip--error .attach-chip__icon { color: var(--danger); }
|
|
|
|
.attach-chip__thumb {
|
|
display: block;
|
|
width: 2.25rem;
|
|
height: 2.25rem;
|
|
border-radius: var(--radius-sm);
|
|
object-fit: cover;
|
|
flex: none;
|
|
}
|
|
.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: var(--z-raised);
|
|
display: none;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: var(--sp-2);
|
|
border: 2px dashed var(--accent);
|
|
border-radius: var(--radius-lg);
|
|
background: color-mix(in srgb, var(--bg) 88%, var(--accent));
|
|
color: var(--accent);
|
|
font-weight: 500;
|
|
/* The overlay must not eat the drop event it is advertising. */
|
|
pointer-events: none;
|
|
}
|
|
.composer.is-dropping .dropzone-overlay { display: flex; }
|
|
|
|
/* --- Attachments in the thread -------------------------------------------- */
|
|
.attachments {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: flex-start;
|
|
gap: var(--sp-2);
|
|
margin-bottom: var(--sp-2);
|
|
}
|
|
/* No frame: an attachment is a picture, and a border around it only ever drew
|
|
at the wrong width. The anchor shrink-wraps its image rather than filling the
|
|
column, and the width/height attributes on the <img> are overridden so a
|
|
small image is shown at its own size instead of being stretched. */
|
|
.attachments__image {
|
|
display: inline-flex;
|
|
max-width: 100%;
|
|
border-radius: var(--radius);
|
|
overflow: hidden;
|
|
line-height: 0;
|
|
}
|
|
.attachments__image img {
|
|
display: block;
|
|
width: auto;
|
|
height: auto;
|
|
max-width: min(22rem, 100%);
|
|
max-height: 20rem;
|
|
object-fit: contain;
|
|
}
|
|
.attachments__doc {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: var(--sp-2);
|
|
padding: var(--sp-2) var(--sp-3);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
background: var(--surface);
|
|
font-size: var(--text-sm);
|
|
max-width: 100%;
|
|
}
|
|
.attachments__doc-body { display: flex; flex-direction: column; min-width: 0; }
|
|
.attachments__doc-body > a { overflow-wrap: anywhere; }
|
|
|
|
/* --- Model avatars -------------------------------------------------------- */
|
|
.model-avatar {
|
|
width: 2rem;
|
|
height: 2rem;
|
|
flex: none;
|
|
border-radius: var(--radius);
|
|
object-fit: cover;
|
|
background: var(--surface-active);
|
|
}
|
|
.model-avatar--initial {
|
|
display: grid;
|
|
place-items: center;
|
|
font-family: var(--font-display);
|
|
font-weight: 600;
|
|
font-size: var(--text-sm);
|
|
/* Hue comes from the model id (see the stable_hue filter); saturation and
|
|
lightness are fixed so every generated badge stays legible in both themes. */
|
|
background: hsl(var(--avatar-hue, 40) 42% 34%);
|
|
color: hsl(var(--avatar-hue, 40) 60% 92%);
|
|
}
|
|
:root[data-theme="shire"] .model-avatar--initial {
|
|
background: hsl(var(--avatar-hue, 40) 46% 82%);
|
|
color: hsl(var(--avatar-hue, 40) 60% 22%);
|
|
}
|
|
|
|
/* --- Theme toggle --------------------------------------------------------- */
|
|
/* Only the icon for the theme you would switch TO is shown. Both live in the
|
|
same button, so the wrapper must not add a line box of its own. */
|
|
.theme-icon { display: flex; }
|
|
:root[data-theme="moria"] .theme-icon--dark { display: none; }
|
|
:root[data-theme="shire"] .theme-icon--light { display: none; }
|
|
|
|
/* Alpine sets x-cloak until it has initialised; without this, collapsed
|
|
folders flash open on every page load. */
|
|
[x-cloak] { display: none !important; }
|
|
|
|
/* --- Unread indicator ------------------------------------------------------ */
|
|
.unread-dot {
|
|
width: 0.5rem;
|
|
height: 0.5rem;
|
|
border-radius: var(--radius-full);
|
|
background: var(--success);
|
|
flex: none;
|
|
/* A ring so it stays visible against the active row's lighter background. */
|
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--success) 25%, transparent);
|
|
}
|
|
.unread-dot[hidden] { display: none; }
|
|
|
|
/* --- 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.
|
|
*/
|
|
|
|
/* The connection. Wide enough to read a machine's name without truncating it
|
|
to three characters, which is what it did while `.select--sm` did not exist.
|
|
A floor rather than a fixed width, so a long name still grows. */
|
|
.composer__connection { min-width: 10rem; }
|
|
|
|
/*
|
|
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);
|
|
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;
|
|
transition: background var(--transition-fast), color var(--transition-fast);
|
|
}
|
|
.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;
|
|
}
|
|
|
|
/* --- A plan, and the way to carry it out ----------------------------------- */
|
|
.plan {
|
|
margin: var(--sp-3) 0;
|
|
padding: var(--sp-4);
|
|
border: 1px solid var(--border-strong);
|
|
border-left: 3px solid var(--accent);
|
|
border-radius: var(--radius-md);
|
|
background: var(--surface);
|
|
}
|
|
.plan__title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
margin: 0 0 var(--sp-3);
|
|
font-size: var(--text-base);
|
|
color: var(--ink);
|
|
}
|
|
.plan__steps {
|
|
margin: 0 0 var(--sp-4);
|
|
padding-left: var(--sp-5);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--sp-2);
|
|
color: var(--ink-muted);
|
|
line-height: var(--leading-relaxed);
|
|
}
|
|
.plan__note { color: var(--ink-faint); font-size: var(--text-xs); }
|