The activity panel, and continuing an agent chat in a terminal
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s

The activity panel beside a chat, from /api/v2's activity routes: Context
(used share, tokens against the window, a segmented bar and a row per
category), Outputs (the files written, lines added and removed, each opening
in the viewer), Background tasks (a team's members as a tree, helpers and
commands: state, elapsed time counting on between reads, model, latest step,
what it is writing, its whole conversation read-only, Stop -- a member after
a confirmation, as it takes those under it), the team's room (who to whom,
reports marked, the words through the page's own markdown) and the plan.

The chat follows GET /chats/{chat}/activity/events while it is open: the strip
above the message box sums the agents up and opens the panel; a new version
reads the panel again while it is open, the costly context part on opening,
after a reply and at most every 15 s. The stream is reopened when the server
closes it, and more slowly while it fails. Beside the chat on a wide window
(the viewer then goes over the chat), over it below --bp-wide, a full-height
sheet at --bp-narrow. The header's Activity button, the palette and Escape
open and close it.

An agent chat with a device session (device_session_id) can be continued in a
terminal: the chat's menu and a header button show 'cd <folder> && lembas -s
<id>' to copy, with lembas the CLI as it starts here (on PATH, else the app's
own copy by its path). Shown rather than run: no terminal opens the same way
on every desktop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-09 10:50:46 +00:00
1 parent 844684a152
commit d04bc4b6bd
10 files changed
+1072 -40

No files matched your search

+51
View File
@@ -42,6 +42,37 @@ pub fn binary() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(exe_name()))
}
/// How a person starts the CLI in a terminal on this computer: `lembas` where one is on PATH
/// (installed by hand: it shares the app's sessions, the directories are the same), else the
/// bundled one by its full path, quoted for a POSIX shell. The bundled copy is never on PATH.
pub fn terminal_word() -> String {
let path = std::env::var_os("PATH").unwrap_or_default();
on_path(&path, exe_name()).map_or_else(
|| shell_quote(&binary().to_string_lossy()),
|_| exe_name().to_string(),
)
}
fn on_path(path: &std::ffi::OsStr, name: &str) -> Option<PathBuf> {
std::env::split_paths(path)
.map(|dir| dir.join(name))
.find(|p| p.is_file())
}
/// A word for a POSIX shell: as it is when it is plainly safe, else in single quotes.
pub fn shell_quote(word: &str) -> String {
let plain = !word.is_empty()
&& word.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, '/' | '.' | '-' | '_' | '+' | ':' | '@' | '%' | '=')
});
if plain {
word.to_string()
} else {
format!("'{}'", word.replace('\'', "'\\''"))
}
}
fn home() -> PathBuf {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
@@ -179,6 +210,26 @@ mod tests {
d
}
#[test]
fn a_word_for_the_shell_is_quoted_only_when_it_must_be() {
assert_eq!(
shell_quote("/usr/lib/llembas-desktop/lembas"),
"/usr/lib/llembas-desktop/lembas"
);
assert_eq!(shell_quote("/home/a b/lembas"), "'/home/a b/lembas'");
assert_eq!(shell_quote("it's"), "'it'\\''s'");
assert_eq!(shell_quote(""), "''");
}
#[test]
fn the_cli_on_path_is_found_by_name() {
let d = scratch("path");
assert_eq!(on_path(d.as_os_str(), "lembas"), None);
fs::write(d.join("lembas"), b"").unwrap();
assert_eq!(on_path(d.as_os_str(), "lembas"), Some(d.join("lembas")));
fs::remove_dir_all(&d).unwrap();
}
#[test]
fn linked_reads_the_logins_the_cli_wrote() {
let d = scratch("linked");
+8
View File
@@ -437,6 +437,13 @@ async fn model_image(handle: AppHandle, path: String) -> Result<String, ApiError
.map_err(|e| ApiError::new(0, e.to_string()))?
}
/// How the CLI is started in a terminal here (`lembas`, or the bundled one by its path): the
/// first word of "continue in a terminal" for an agent chat on this computer.
#[tauri::command]
fn terminal_word() -> String {
cli::terminal_word()
}
/// A frame of a reply being written, as the page hears it.
#[derive(Clone, Serialize)]
struct Frame {
@@ -1436,6 +1443,7 @@ pub fn run() {
api,
api_text,
model_image,
terminal_word,
stream_open,
stream_close,
local_list,
+195 -8
View File
@@ -1,6 +1,7 @@
/* LLeMbas Desktop's page. Every colour, size, space and font is a token from tokens.css (the
server's, copied by scripts/fetch-tokens.sh and pinned); the only values of this page's own are
the title bar's height and the setup card's width, named below. Media queries cannot read a
the title bar's height, the setup card's width and the activity panel's number columns, named
below. Media queries cannot read a
custom property, so the two breakpoints are written out and named after the tokens they are:
48rem is --bp-narrow (the sidebar becomes a drawer), 64rem is --bp-wide (the viewer goes over
the chat instead of beside it).
@@ -18,6 +19,10 @@
--card-max: 40rem;
/* The sidebar toggle's track in the title bar: none until the sidebar is a drawer. */
--side-btn-w: 0px;
/* The activity panel's number columns: a token count or a share ("180k", "63.0%"), and an
elapsed time ("2h 03m"), in the panel's monospaced figures. */
--num-col: 4.5em;
--time-col: 5.5em;
}
html, body { margin: 0; height: 100%; overflow: hidden; }
@@ -376,7 +381,7 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
/* --- The chat ------------------------------------------------------------------------------ */
.main, .settings { grid-area: main; min-width: 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr) auto auto; }
.main, .settings { grid-area: main; min-width: 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr) auto auto auto; }
.settings { grid-template-rows: auto minmax(0, 1fr); }
.head {
@@ -495,23 +500,20 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
its word before them -- fixed tracks, so nothing moves when a number grows a digit. */
.agents {
display: inline-grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas: "min max";
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: var(--sp-2);
align-items: center;
width: 100%;
height: var(--control-h);
}
.agents__dash { display: none; }
.agents__dash { color: var(--ink-muted); }
.agents__input { width: 100%; min-width: 0; height: var(--control-h); padding: 0 var(--control-px-sm); }
.agents--compact {
grid-template-columns: auto calc(var(--w-field-xs) / 2) auto calc(var(--w-field-xs) / 2);
grid-template-areas: none;
width: auto;
height: var(--control-h-sm);
font: var(--text-sm) var(--font-ui);
}
.agents--compact .agents__dash { display: inline; color: var(--ink-muted); }
.agents--compact .agents__input { height: var(--control-h-sm); }
.agents__label { color: var(--ink-muted); white-space: nowrap; }
/* The model picker: a button the height of a control, avatar · name · chevron. */
@@ -783,6 +785,173 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
.viewer__note { padding: var(--sp-4); font-size: var(--text-sm); color: var(--ink-muted); }
.viewer__note.is-error { color: var(--warning); }
/* --- The activity panel -------------------------------------------------------------------------
Beside the chat, the inspector's width; while the viewer is open too, the viewer goes over the
chat (it was opened from here, and three columns of text beside the sidebar do not fit). Over
the chat from --bp-wide down, as the viewer is; at --bp-narrow that is a full-height sheet. */
.shell[data-activity="true"],
.shell[data-activity="true"][data-viewer="true"] {
grid-template-columns: var(--sidebar-width) minmax(0, 1fr) var(--inspector-width);
grid-template-areas: "side main activity";
}
.shell[data-activity="true"] .viewer { grid-area: main; z-index: var(--z-panel); border-left: 0; }
.activity {
grid-area: activity;
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-left: var(--border-w) solid var(--border);
background: var(--bg);
}
/* Title and close on two known tracks; with an agent's conversation open, Back before them. */
.activity__head { grid-template-columns: minmax(0, 1fr) var(--control-h-sm); }
.activity[data-view="agent"] .activity__head { grid-template-columns: var(--control-h-sm) minmax(0, 1fr) var(--control-h-sm); }
.activity__body { overflow: auto; min-height: 0; display: grid; align-content: start; font-size: var(--text-sm); }
.activity__section { padding: var(--sp-3); border-bottom: var(--border-w) solid var(--border); min-width: 0; }
.activity__section:last-child { border-bottom: 0; }
.activity__heading, .activity__subheading {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-2);
align-items: center;
margin: 0;
font: 600 var(--text-sm) / var(--leading-tight) var(--font-ui);
color: var(--ink);
cursor: pointer;
}
.activity__heading::-webkit-details-marker { display: none; }
.activity__section[open] > .activity__heading { margin-bottom: var(--sp-3); }
.activity__subheading { margin: var(--sp-4) 0 var(--sp-2); cursor: default; }
.activity__count { font: var(--text-xs) var(--font-mono); color: var(--ink-muted); }
.activity__empty { margin: 0; padding: var(--sp-1) 0; color: var(--ink-faint); font-size: var(--text-sm); }
.activity__empty.is-error { color: var(--warning); padding: var(--sp-3); }
.activity__plan { min-width: 0; }
/* Context: the line, the bar, then one row per category on four fixed tracks -- swatch, the name
that gives, and two number columns -- so the numbers stand in columns in any language. The
categories' colours are tokens, one each. */
.ctx { display: grid; gap: var(--sp-2); }
.ctx__used { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--sp-2); margin: 0; color: var(--ink); }
.ctx__of { font-family: var(--font-mono); color: var(--ink-muted); }
.ctx__bar { display: flex; height: var(--sp-2); overflow: hidden; background: var(--surface-active); border-radius: var(--radius-full); }
.ctx__seg { flex: none; width: var(--share, 0); background: var(--ctx, var(--ink-faint)); }
.ctx__rows { display: grid; margin: 0; padding: 0; list-style: none; }
.ctx__row {
display: grid;
grid-template-columns: var(--sp-3) minmax(0, 1fr) var(--num-col) var(--num-col);
gap: var(--sp-2);
align-items: center;
min-height: var(--row-h);
}
.ctx__key { width: var(--sp-3); height: var(--sp-3); border-radius: var(--radius-sm); background: var(--ctx, var(--ink-faint)); }
.ctx--free.ctx__key { background: transparent; border: var(--border-w) solid var(--border-strong); }
.ctx__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--ink-muted); }
.ctx__num { font: var(--text-xs) var(--font-mono); text-align: right; color: var(--ink-muted); }
.ctx__note { margin: 0; font-size: var(--text-xs); color: var(--ink-faint); }
.ctx--messages { --ctx: var(--accent); }
.ctx--system { --ctx: var(--ink-muted); }
.ctx--memory { --ctx: var(--warning); }
.ctx--instructions { --ctx: var(--success); }
.ctx--tools { --ctx: var(--ink); }
.ctx--mcp_tools { --ctx: var(--danger); }
.ctx--since { --ctx: var(--ink-faint); }
.ctx--buffer { --ctx: var(--border-strong); }
.ctx--free { --ctx: transparent; }
/* Outputs: icon, the name (cut, never wrapped), the lines added and removed. */
.outs { display: grid; margin: 0; padding: 0; list-style: none; }
.outs__row { display: grid; grid-template-columns: var(--sp-4) minmax(0, 1fr) auto; gap: var(--sp-2); align-items: center; min-height: var(--row-h); color: var(--ink-muted); }
.outs__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; padding: 0; font: var(--text-sm) var(--font-ui); color: var(--ink); text-align: left; background: none; border: 0; cursor: pointer; }
.outs__name:hover { color: var(--accent); text-decoration: underline; }
.outs__diff { display: grid; grid-auto-flow: column; gap: var(--sp-2); font: var(--text-xs) var(--font-mono); }
.outs__add { color: var(--success); }
.outs__del { color: var(--danger); }
/* Background tasks. An agent's row is one grid of known tracks: state dot, what it is (the one
track that gives, cut), elapsed time in a column of its own width, the stop button at its size
(an empty box where there is none) -- so rows line up whatever each says. */
.tasks { display: grid; gap: var(--sp-2); min-width: 0; }
.tasks__summary, .tasks__team { margin: 0; color: var(--ink-muted); }
.tasks__goal { margin: 0; color: var(--ink); }
.tasks__list { display: grid; gap: var(--sp-1); }
.tasks__more > summary { cursor: pointer; padding: var(--sp-1) 0; color: var(--ink-muted); }
.tasks__more[open] > summary { margin-bottom: var(--sp-1); }
.agent { margin-left: calc(var(--depth, 0) * var(--sp-4)); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius); min-width: 0; }
.agent__row {
display: grid;
grid-template-columns: var(--dot-size) minmax(0, 1fr) var(--time-col) var(--control-h-sm);
gap: var(--sp-2);
align-items: center;
min-height: var(--control-h);
padding: 0 var(--sp-1) 0 var(--sp-3);
cursor: pointer;
list-style: none;
}
.agent__row::-webkit-details-marker { display: none; }
.agent__dot { width: var(--dot-size); height: var(--dot-size); border-radius: var(--radius-full); background: var(--ink-faint); }
.agent.is-working .agent__dot { background: var(--accent); }
.agent.is-queued .agent__dot, .agent.is-waiting .agent__dot { background: var(--warning); }
.agent.is-done .agent__dot { background: var(--leaf); }
.agent.is-error .agent__dot { background: var(--danger); }
.agent__what { display: grid; min-width: 0; }
.agent__name, .agent__role { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.agent__name { color: var(--ink); }
.agent__role { font-size: var(--text-xs); color: var(--ink-muted); }
.agent__time { font: var(--text-xs) var(--font-mono); text-align: right; color: var(--ink-muted); }
.agent__stop { width: var(--control-h-sm); height: var(--control-h-sm); }
.agent__body { display: grid; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3) var(--sp-3); border-top: var(--border-w) solid var(--border); min-width: 0; }
.agent__body > .btn { justify-self: start; }
.agent__facts { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); margin: 0; font-size: var(--text-xs); color: var(--ink-muted); }
.agent__state { color: var(--ink); }
.agent__model { font-family: var(--font-mono); overflow-wrap: anywhere; }
.agent__brief { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink); }
.agent__step { display: grid; grid-template-columns: var(--dot-size) auto minmax(0, 1fr); gap: var(--sp-2); align-items: center; margin: 0; }
.agent__verb { color: var(--ink); white-space: nowrap; }
.agent__title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font: var(--text-xs) var(--font-mono); color: var(--ink-muted); }
.agent__text { margin: 0; max-height: calc(6 * var(--row-h)); overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--text-xs); color: var(--ink-muted); }
/* The team's room: who to whom, a report marked, the words as markdown. */
.room { display: grid; gap: var(--sp-2); margin: 0; padding: 0; list-style: none; }
.room__entry { display: grid; gap: var(--sp-1); padding: var(--sp-2) var(--sp-3); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius); min-width: 0; }
.room__entry.is-report { border-left: var(--border-w-thick) solid var(--leaf); }
.room__meta { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--sp-2); align-items: center; font-size: var(--text-xs); color: var(--ink-muted); }
.room__n { font-family: var(--font-mono); }
.room__route { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.room__badge { color: var(--leaf); border-color: var(--leaf); }
/* One agent's conversation, read-only. */
.convo__head { display: grid; gap: var(--sp-2); padding: var(--sp-3); border-bottom: var(--border-w) solid var(--border); }
.convo__head > .btn { justify-self: start; }
.convo { display: grid; gap: var(--sp-3); padding: var(--sp-3); min-width: 0; }
.convo__turn { font-size: var(--text-sm); }
.convo__tools { display: grid; gap: var(--sp-1); margin: var(--sp-2) 0 0; padding: 0; list-style: none; }
.convo__tool { display: grid; grid-template-columns: var(--dot-size) auto minmax(0, 1fr); gap: var(--sp-2); align-items: center; font-size: var(--text-xs); }
/* The strip above the message box: what the agents are doing, one line, and the way into the panel. */
.strip { padding: var(--sp-2) var(--sp-4) 0; }
.strip__btn {
display: grid;
grid-template-columns: var(--dot-size) minmax(0, 1fr) auto;
gap: var(--sp-2);
align-items: center;
width: 100%;
max-width: var(--thread-max-width);
min-height: var(--control-h);
margin: 0 auto;
padding: 0 var(--control-px-sm);
font: var(--text-sm) var(--font-ui);
color: var(--ink-muted);
text-align: left;
background: var(--surface);
border: var(--border-w) solid var(--border);
border-radius: var(--radius);
cursor: pointer;
}
.strip__btn:hover { color: var(--ink); background: var(--surface-hover); }
.strip__text { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
/* --- Menus, dialogs and the palette ----------------------------------------------------------- */
.menu {
@@ -836,6 +1005,19 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
.dialog::backdrop { background: var(--scrim); }
.dialog__body { display: grid; gap: var(--sp-3); padding: var(--sp-4); }
.dialog__title { font: 600 var(--text-md) / var(--leading-tight) var(--font-ui); }
/* A line to copy: as it is, on one line that scrolls on its own rather than the dialog. */
.dialog__code {
margin: 0;
padding: var(--sp-2) var(--sp-3);
overflow-x: auto;
white-space: pre;
font: var(--text-sm) var(--font-mono);
color: var(--ink);
background: var(--code-bg);
border: var(--border-w) solid var(--code-border);
border-radius: var(--radius);
user-select: all;
}
.dialog__input { width: 100%; }
.dialog__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-2); }
@@ -866,6 +1048,9 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
@media (max-width: 64rem) {
.shell[data-viewer="true"] { grid-template-columns: var(--sidebar-width) minmax(0, 1fr); grid-template-areas: "side main"; }
.viewer { grid-area: main; z-index: var(--z-panel); border-left: 0; }
.shell[data-activity="true"],
.shell[data-activity="true"][data-viewer="true"] { grid-template-columns: var(--sidebar-width) minmax(0, 1fr); grid-template-areas: "side main"; }
.activity { grid-area: main; z-index: var(--z-panel); border-left: 0; }
}
/* 48rem (--bp-narrow): the sidebar is a drawer, opened from the title bar. */
@@ -873,7 +1058,8 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
:root { --side-btn-w: var(--titlebar-h); }
.bar__side { visibility: visible; }
.bar__host { display: none; }
.shell, .shell[data-viewer="true"] { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main"; }
.shell, .shell[data-viewer="true"],
.shell[data-activity="true"], .shell[data-activity="true"][data-viewer="true"] { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main"; }
.side {
position: absolute;
inset: 0 auto 0 0;
@@ -893,6 +1079,7 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
.head__tools:empty { display: none; }
.thread { padding: var(--sp-3); }
.composer { padding: var(--sp-2) var(--sp-3) var(--sp-3); }
.strip { padding: var(--sp-2) var(--sp-3) 0; }
.cards { padding: var(--sp-2) var(--sp-3) 0; }
.card { padding: var(--sp-4); gap: var(--sp-4); }
.msg--user { max-width: 100%; }
+10
View File
@@ -87,6 +87,7 @@
<div class="messages" id="messages"></div>
</div>
<div class="cards" id="cards" hidden></div>
<div class="strip" id="activity-strip" hidden aria-live="polite"></div>
<form class="composer" id="composer" hidden data-running="false" data-drag="false">
<ul class="composer__files" id="composer-files" hidden></ul>
<button type="button" class="btn composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
@@ -108,6 +109,15 @@
<div class="settings__body" id="settings-body"></div>
</section>
<aside class="activity" id="activity" hidden aria-labelledby="activity-title">
<header class="viewer__head activity__head">
<button type="button" class="pane__btn" id="activity-back" hidden aria-label="Back" title="Back" data-i18n-aria-label data-i18n-title></button>
<h2 class="viewer__name" id="activity-title"></h2>
<button type="button" class="pane__btn" id="activity-close" aria-label="Close the activity panel" title="Close the activity panel" data-i18n-aria-label data-i18n-title></button>
</header>
<div class="activity__body" id="activity-body"></div>
</aside>
<aside class="viewer" id="viewer" hidden aria-labelledby="viewer-name">
<header class="viewer__head">
<div class="viewer__id">
+667
View File
@@ -0,0 +1,667 @@
// The chat's activity panel, beside it: what is in its context, by category, against the model's
// window; the files the chat and its agents wrote; every background agent -- a team's members as
// a tree, helpers and commands -- with its state, how long it has run, its model, its latest step
// and what it is writing, its whole conversation read-only and a button to stop it; the team's
// room; and the plan. The strip above the message box sums the agents up and opens it. The same
// facts as the server's own panel (`services/activity.py`), from /api/v2:
//
// GET /chats/{chat}/activity?since=&context= everything, with a `version`
// GET /chats/{chat}/activity/events `activity` {version, summary} on each change
// GET /chats/{chat}/agents/{kind}/{id} one agent and its conversation
// POST /chats/{chat}/agents/{kind}/{id}/stop
//
// The events stream is followed for as long as a chat is open: it keeps the strip current, and
// while the panel is open a new `version` reads the panel again. The context is the costly part
// of a read; it is asked for when the panel opens, after a reply and at most every so often.
//
// Every name, role, brief, step, room message and conversation here is a model's words: set as
// text, and a room message or a reply through the page's own markdown (markdown.js), which
// builds elements and never parses HTML.
import { $, h, clear, icon, ICONS } from "./dom.js"
import { get, post, follow, why } from "./bridge.js"
import { markdown } from "./markdown.js"
import { ask } from "./popup.js"
import { openServerFile } from "./explorer.js"
const ACTIVE = ["queued", "working", "waiting"]
// How many live agents show before "Show N more", as on the server's panel.
const SHOWN = 6
// The context is read again on a change at most this often (ms); always on opening and after a reply.
const CONTEXT_EVERY = 15000
let layout = () => {} // (on) -> the shell makes room for the panel
let chat = null // the open chat: { id, kind, … }
let stream = null // the events stream's handle
let retry = 0 // the reconnect timer
let failures = 0
let summary = null
let version = ""
let data = null // the last `activity`
let room = [] // the room so far: each read asks only for what came after `cursor`
let cursor = 0
let readAt = 0 // when `data` was read: the elapsed times count on from there
let contextAt = 0
let reading = null // the read in flight
let readAgain = "" // "", "plain" or "context": what to read again once the read in flight is done
let view = null // null: the panel; { kind, id, name }: one agent's conversation
const openRows = new Set() // agents unfolded, by kind:id; sections folded, by name
let ticker = 0
// --- Words ----------------------------------------------------------------------------------------
/** A duration as both harnesses write one: 5s, 1m 05s, 2h 03m. */
export function duration(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0))
if (total < 60) return `${total}s`
const minutes = Math.floor(total / 60)
if (minutes < 60) return `${minutes}m ${String(total % 60).padStart(2, "0")}s`
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`
}
/** A token count at a glance: 950, 14.2k, 180k, 1.54M (the server's `compact_tokens`). */
export function compact(value) {
const n = Math.trunc(Number(value) || 0)
const trim = (x, digits) => x.toFixed(digits).replace(/\.?0+$/, "")
if (n < 1000) return String(n)
if (n < 100000) return `${trim(n / 1000, 1)}k`
if (n < 1000000) return `${Math.round(n / 1000)}k`
return `${trim(n / 1000000, 2)}M`
}
/** "3 agents working · 1 queued · 2 finished", or "" for none (the server's `summary_text`). */
export function summaryText(counts) {
if (!counts) return ""
const parts = []
if (counts.working === 1) parts.push(t("1 agent working"))
else if (counts.working) parts.push(t("%(n)s agents working", { n: counts.working }))
if (counts.queued) parts.push(t("%(n)s queued", { n: counts.queued }))
if (counts.waiting) parts.push(t("%(n)s waiting", { n: counts.waiting }))
if (counts.finished) parts.push(t("%(n)s finished", { n: counts.finished }))
return parts.join(" · ")
}
function stateWord(state) {
return { queued: t("queued"), working: t("working"), waiting: t("waiting"), done: t("done"), stopped: t("stopped"), error: t("failed") }[state] || String(state || "")
}
function contextWord(key) {
return (
{
messages: t("Messages"),
system: t("System prompt"),
memory: t("Memory and personal"),
instructions: t("Instruction files"),
tools: t("Tools"),
mcp_tools: t("MCP tools"),
since: t("Since the last reply"),
buffer: t("Compaction buffer"),
free: t("Free space"),
}[key] || String(key)
)
}
/** A percentage through a sentence written the server's way (`%%` is a percent sign). */
function percentSentence(text, fields) {
return t(text, fields).replace(/%%/g, "%")
}
/** A plan's phases and their tasks, done ones struck through: the panel's and the cards'. */
export function planList(plan) {
return h(
"ol",
{ class: "plan" },
(plan.phases || []).map((p) =>
h("li", null, h("span", { class: "plan__phase", text: p.title }), h("ul", null, (p.tasks || []).map((task) => h("li", { class: `plan__task is-${task.status}`, text: task.text })))),
),
)
}
// --- Following a chat -----------------------------------------------------------------------------
const enc = encodeURIComponent
export function panelOpen() {
return !$("activity").hidden
}
/** The chat whose activity is followed: the one open, or null for none (a new chat, the settings). */
export function watchActivity(next) {
const id = next?.id || null
if (id && chat?.id === id) {
chat = next
return
}
stop()
chat = id ? next : null
summary = null
version = ""
data = null
room = []
cursor = 0
readAt = contextAt = 0
view = null
openRows.clear()
renderStrip()
$("activity-open")?.setAttribute("aria-expanded", String(panelOpen()))
if (!chat) {
if (panelOpen()) showPanel(false)
return
}
listenTo(chat.id)
if (panelOpen()) {
render()
read(true)
}
}
/** A reply has ended, or the chat's settings changed: the context and the outputs move with it. */
export function activityChanged() {
if (chat && panelOpen()) read(true)
}
function stop() {
clearTimeout(retry)
retry = 0
failures = 0
stream?.close()
stream = null
}
async function listenTo(id) {
try {
const handle = await follow(`/chats/${enc(id)}/activity/events`, {
onFrame: (event, frame) => {
if (chat?.id !== id || event !== "activity" || !frame || typeof frame !== "object") return
failures = 0
heard(frame)
},
// The server closes the stream after a while (`close`): it is opened again at once. A broken
// one is opened again later, more slowly each time -- polling, in effect, while it fails.
onEnd: (err) => {
if (chat?.id !== id) return
stream = null
failures = err ? failures + 1 : 0
retry = setTimeout(() => {
if (chat?.id === id && !stream) {
if (err && panelOpen()) read(false)
listenTo(id)
}
}, err ? Math.min(60000, 5000 * failures) : 500)
},
})
if (chat?.id !== id) handle.close()
else stream = handle
} catch {
if (chat?.id !== id) return
failures += 1
retry = setTimeout(() => chat?.id === id && listenTo(id), Math.min(60000, 5000 * failures))
}
}
function heard(frame) {
summary = frame.summary || summary
renderStrip()
if (frame.version && frame.version !== version) {
version = frame.version
if (panelOpen()) read(Date.now() - contextAt > CONTEXT_EVERY)
}
}
/** Read the panel's facts; one read at a time, and one more after it if asked for meanwhile. */
async function read(withContext) {
if (!chat) return
if (reading) {
readAgain = withContext ? "context" : readAgain || "plain"
return reading
}
const id = chat.id
reading = (async () => {
try {
const a = (await get(`/chats/${enc(id)}/activity?since=${cursor}&context=${withContext ? "true" : "false"}`)).activity
if (chat?.id !== id || !a) return
if (!withContext) a.context = data?.context ?? null
else contextAt = Date.now()
if (a.team) {
// A cursor lower than ours is a room begun again (a new team): read from the start.
if (a.team.cursor < cursor) {
room = []
cursor = 0
readAgain = readAgain || "plain"
}
for (const m of a.team.room || []) if (m.n > cursor) room.push(m)
cursor = Math.max(cursor, a.team.cursor || 0)
} else {
room = []
cursor = 0
}
data = a
version = a.version || version
summary = a.summary || summary
readAt = Date.now()
renderStrip()
if (view) await readAgent()
else render()
} catch (err) {
if (chat?.id === id && !data) renderError(why(err))
}
})()
await reading
reading = null
if (readAgain) {
const ctxToo = readAgain === "context"
readAgain = ""
read(ctxToo)
}
}
// --- The strip above the message box ---------------------------------------------------------------
function renderStrip() {
const strip = $("activity-strip")
const text = chat && summary?.total ? summaryText(summary) : ""
strip.hidden = !text
if (!text) return clear(strip)
const busy = summary.active > 0
strip.replaceChildren(
h(
"button",
{ type: "button", class: "strip__btn", "aria-label": `${text} — ${t("Open the activity panel")}`, onclick: () => showPanel(true) },
h("span", { class: `dot${busy ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
h("span", { class: "strip__text", text }),
icon(ICONS.chevron),
),
)
}
// --- The panel ------------------------------------------------------------------------------------------
/** Open or close the panel (the header's Activity button, the strip, Escape). */
export function showPanel(on) {
if (on && !chat) return
$("activity").hidden = !on
layout(on)
$("activity-open")?.setAttribute("aria-expanded", String(on))
clearInterval(ticker)
ticker = 0
if (!on) {
view = null
return
}
ticker = setInterval(tick, 1000)
render()
read(true)
$("activity-close").focus()
}
export function togglePanel() {
showPanel(!panelOpen())
}
/** The elapsed times of agents still at work count on between reads. */
function tick() {
if (document.visibilityState !== "visible") return
const extra = (Date.now() - readAt) / 1000
for (const el of document.querySelectorAll("#activity .agent__time[data-active]")) {
el.textContent = duration(Number(el.dataset.seconds) + extra)
}
}
function renderError(text) {
clear($("activity-body")).append(h("p", { class: "activity__empty is-error", text }))
}
function section(name, title, count, ...body) {
const el = h(
"details",
{ class: "activity__section", open: !openRows.has(`section:${name}`) },
h("summary", { class: "activity__heading" }, h("span", { text: title }), count ? h("span", { class: "activity__count", text: String(count) }) : null),
...body,
)
el.addEventListener("toggle", () => (el.open ? openRows.delete(`section:${name}`) : openRows.add(`section:${name}`)))
return el
}
function render() {
$("activity").dataset.view = "panel"
const head = $("activity-title")
$("activity-back").hidden = true
head.textContent = t("Activity")
const body = $("activity-body")
if (!data) {
clear(body).append(h("p", { class: "activity__empty", text: t("Loading…") }))
return
}
// Kept where the reader was: the panel is drawn again on every change.
const scroll = body.scrollTop
body.replaceChildren(
section("context", t("Context"), 0, contextPart(data.context)),
section("outputs", t("Outputs"), (data.outputs || []).length, outputsPart(data.outputs || [])),
section("tasks", t("Background tasks"), 0, tasksPart()),
data.plan ? section("plan", t("Plan"), 0, h("div", { class: "activity__plan" }, planList(data.plan))) : null,
)
body.scrollTop = scroll
}
function contextPart(c) {
if (!c) return h("p", { class: "activity__empty", text: t("Not measured.") })
const known = c.percent !== null && c.percent !== undefined
const used = h(
"p",
{ class: "ctx__used" },
h("span", { text: known ? percentSentence("%(percent)s%% used", { percent: c.percent }) : t("%(tokens)s tokens", { tokens: compact(c.used) }) }),
h("span", { class: "ctx__of", text: known ? `${compact(c.used)} / ${compact(c.limit)}` : t("window unknown") }),
)
const cats = c.categories || []
const bar = c.limit
? h(
"div",
{ class: "ctx__bar", role: "img", "aria-label": percentSentence("%(percent)s%% of the context window used", { percent: c.percent ?? 0 }) },
cats
.filter((r) => r.key !== "free" && r.tokens)
.map((r) => {
const seg = h("span", { class: `ctx__seg ctx--${safeKey(r.key)}` })
seg.style.setProperty("--share", `${Math.max(0, Math.min(100, Number(r.percent) || 0))}%`)
return seg
}),
)
: null
const rows = h(
"ul",
{ class: "ctx__rows" },
cats.map((r) =>
h(
"li",
{ class: "ctx__row" },
h("span", { class: `ctx__key ctx--${safeKey(r.key)}`, "aria-hidden": "true" }),
h("span", { class: "ctx__name", text: contextWord(r.key) }),
h("span", { class: "ctx__num", text: compact(r.tokens) }),
h("span", { class: "ctx__num", text: r.percent === null || r.percent === undefined ? "—" : `${r.percent}%` }),
),
),
)
const note = h(
"p",
{ class: "ctx__note" },
`${c.measured ? t("Counted by the model at the last reply; the categories are estimated.") : t("Estimated: no reply has been counted yet.")} ${t("%(n)s tools offered.", { n: c.tools_count ?? 0 })}`,
)
return h("div", { class: "ctx" }, used, bar, rows, note)
}
/** A category's key as a class name: the server's words, kept to letters and underscores. */
function safeKey(key) {
return String(key).replace(/[^a-z_]/gi, "") || "other"
}
function outputsPart(outputs) {
if (!outputs.length) return h("p", { class: "activity__empty", text: t("No files written in this chat yet.") })
return h(
"ul",
{ class: "outs" },
outputs.map((o) =>
h(
"li",
{ class: "outs__row" },
icon(ICONS.file),
h("button", { type: "button", class: "outs__name", title: `${o.path} — ${t("Open in the viewer")}`, onclick: () => openServerFile(o.path, o.name) }, h("bdi", { text: o.name || o.path })),
h(
"span",
{ class: "outs__diff" },
o.added === null || o.added === undefined ? null : [h("span", { class: "outs__add", text: `+${o.added}` }), h("span", { class: "outs__del", text: `−${o.removed ?? 0}` })],
),
),
),
)
}
function tasksPart() {
const entries = data.agents || []
const team = data.team
const box = h("div", { class: "tasks", "aria-live": "polite" })
if (!entries.length && !team) {
box.append(h("p", { class: "activity__empty", text: t("Nothing runs in the background. Helpers, commands and a team's members show here while they work.") }))
return box
}
box.append(h("p", { class: "tasks__summary", text: summaryText(data.summary) || t("Nothing running.") }))
if (team) {
box.append(h("p", { class: "tasks__team", text: t("Team: %(size)s of %(max)s agents", { size: team.size, max: team.max }) }))
if (team.goal) box.append(h("p", { class: "tasks__goal", text: team.goal }))
}
const live = entries.filter((e) => ACTIVE.includes(e.state))
const ended = entries.filter((e) => !ACTIVE.includes(e.state))
if (live.length) {
box.append(h("div", { class: "tasks__list" }, live.slice(0, SHOWN).map(agentRows)))
if (live.length > SHOWN) box.append(fold("more", t("Show %(n)s more", { n: live.length - SHOWN }), live.slice(SHOWN)))
}
if (ended.length) box.append(fold("finished", t("%(n)s finished", { n: ended.length }), ended))
if (team) {
box.append(h("h3", { class: "activity__subheading", text: t("Room") }))
box.append(
room.length
? h(
"ol",
{ class: "room" },
room.map((m) =>
h(
"li",
{ class: `room__entry${m.report ? " is-report" : ""}` },
h(
"div",
{ class: "room__meta" },
h("span", { class: "room__n", text: `#${m.n}` }),
h("span", { class: "room__route", text: `${m.from} → ${(m.to || []).join(", ")}` }),
m.report ? h("span", { class: "chip room__badge", text: t("report") }) : null,
),
h("div", { class: "room__text md" }, markdown(String(m.text || ""))),
),
),
)
: h("p", { class: "activity__empty", text: t("Nothing has been said in the room yet.") }),
)
}
return box
}
/** A folded list of agents ("Show 3 more", "12 finished"), remembered open across redraws. */
function fold(name, label, list) {
const el = h("details", { class: "tasks__more", open: openRows.has(`fold:${name}`) }, h("summary", { text: label }), h("div", { class: "tasks__list" }, list.map(agentRows)))
el.addEventListener("toggle", () => (el.open ? openRows.add(`fold:${name}`) : openRows.delete(`fold:${name}`)))
return el
}
/** An agent and, under it, the agents it leads or started. */
function agentRows(a) {
return [agentRow(a), ...(a.children || []).map(agentRows)]
}
function agentRow(a) {
const key = `${a.kind}:${a.id}`
const active = ACTIVE.includes(a.state)
const time = h("span", { class: "agent__time", title: stateWord(a.state), text: duration(a.seconds) })
if (active) {
time.dataset.active = ""
time.dataset.seconds = String(Number(a.seconds) || 0)
}
const stopBtn = a.stoppable
? h(
"button",
{
type: "button",
class: "pane__btn agent__stop",
"aria-label": t("Stop %(name)s", { name: a.name }),
title: t("Stop %(name)s", { name: a.name }),
onclick: (e) => {
e.preventDefault()
e.stopPropagation()
stopAgent(a)
},
},
icon(ICONS.stop),
)
: h("span", { class: "agent__stop", "aria-hidden": "true" })
const facts = h(
"p",
{ class: "agent__facts" },
h("span", { class: "agent__state", text: stateWord(a.state) }),
a.agent ? h("span", { text: a.agent }) : null,
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
a.steps ? h("span", { text: t("%(steps)s steps, %(tools)s tools", { steps: a.steps, tools: a.tools }) }) : null,
)
const step = a.step
? h(
"p",
{ class: `agent__step${a.step.error ? " is-error" : ""}` },
h("span", { class: `dot${a.step.error ? " is-error" : a.step.running ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
h("span", { class: "agent__verb", text: a.step.label || a.step.tool || "" }),
a.step.title ? h("code", { class: "agent__title", text: a.step.title }) : null,
)
: null
const el = h(
"details",
{ class: `agent is-${safeKey(a.state)}`, open: openRows.has(key) },
h(
"summary",
{ class: "agent__row" },
h("span", { class: "agent__dot", "aria-hidden": "true" }),
h("span", { class: "agent__what" }, h("span", { class: "agent__name", text: a.name || a.id }), a.role ? h("span", { class: "agent__role", text: a.role }) : null),
time,
stopBtn,
),
h(
"div",
{ class: "agent__body" },
facts,
a.brief ? h("p", { class: "agent__brief", text: a.brief }) : null,
step,
a.text ? h("p", { class: "agent__text", text: a.text }) : null,
a.transcript
? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => openAgent(a) })
: null,
),
)
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, Number(a.depth) || 0))))
el.addEventListener("toggle", () => (el.open ? openRows.add(key) : openRows.delete(key)))
return el
}
async function stopAgent(a) {
if (!chat) return
if (a.kind === "member") {
const yes = await ask({
title: t("Stop %(name)s", { name: a.name }),
text: t("Stop %(name)s and the members under it? They leave the team.", { name: a.name }),
ok: t("Stop"),
cancel: t("Cancel"),
danger: true,
})
if (!yes) return
}
try {
await post(`/chats/${enc(chat.id)}/agents/${enc(a.kind)}/${enc(a.id)}/stop`)
} catch (err) {
// 409: it had finished meanwhile; the read below shows that.
if (err?.status !== 409) renderError(why(err))
}
read(false)
}
// --- One agent's conversation, read-only ------------------------------------------------------------
function openAgent(a) {
view = { kind: a.kind, id: a.id, name: a.name || a.id }
$("activity").dataset.view = "agent"
$("activity-title").textContent = view.name
$("activity-back").hidden = false
clear($("activity-body")).append(h("p", { class: "activity__empty", text: t("Loading…") }))
readAgent()
}
function closeAgent() {
view = null
render()
$("activity-close").focus()
}
async function readAgent() {
if (!chat || !view) return
const { kind, id } = view
let r
try {
r = await get(`/chats/${enc(chat.id)}/agents/${enc(kind)}/${enc(id)}`)
} catch (err) {
if (view?.id === id) clear($("activity-body")).append(h("p", { class: "activity__empty is-error", text: why(err) }))
return
}
if (view?.kind !== kind || view?.id !== id) return
const a = r.agent || {}
const body = $("activity-body")
const atEnd = body.scrollHeight - body.scrollTop - body.clientHeight < 40
const head = h(
"div",
{ class: "convo__head" },
h(
"p",
{ class: "agent__facts" },
h("span", { class: "agent__state", text: stateWord(a.state) }),
h("span", { text: duration(a.seconds) }),
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
),
a.role ? h("p", { class: "agent__role", text: a.role }) : null,
a.stoppable ? h("button", { type: "button", class: "btn btn--sm", text: t("Stop %(name)s", { name: a.name || id }), onclick: () => stopAgent(a) }) : null,
)
const turns = (r.transcript || []).map((turn) => {
if (turn.role === "user") {
return h("article", { class: `msg msg--user convo__turn${turn.machine ? " is-machine" : ""}` }, h("div", { class: "msg__text", text: String(turn.text || "") }))
}
return h(
"article",
{ class: "msg msg--assistant convo__turn" },
turn.text ? h("div", { class: "md" }, markdown(String(turn.text))) : null,
turn.tools?.length
? h(
"ul",
{ class: "convo__tools" },
turn.tools.map((c) =>
h(
"li",
{ class: "convo__tool" },
h("span", { class: `dot${c.isError ? " is-error" : " is-ok"}`, "aria-hidden": "true" }),
h("span", { class: "agent__verb", text: String(c.name || "") }),
c.title ? h("code", { class: "agent__title", text: String(c.title) }) : null,
),
),
)
: null,
turn.error ? h("p", { class: "msg__note is-error", text: String(turn.error) }) : null,
turn.running ? h("p", { class: "msg__status muted", text: t("Writing…") }) : null,
)
})
body.replaceChildren(head, h("div", { class: "convo" }, turns.length ? turns : h("p", { class: "activity__empty", text: t("Nothing yet.") })))
if (atEnd) body.scrollTop = body.scrollHeight
}
// --- Start ------------------------------------------------------------------------------------------
export function initActivity({ onLayout }) {
layout = onLayout || layout
$("activity-close").replaceChildren(icon(ICONS.close))
$("activity-close").addEventListener("click", () => showPanel(false))
$("activity-back").replaceChildren(icon(ICONS.back))
$("activity-back").addEventListener("click", closeAgent)
// Before the viewer's own Escape (capture): while the viewer is open, it closes first and the
// panel stays; one Escape closes one thing.
document.addEventListener(
"keydown",
(e) => {
if (e.key !== "Escape" || !panelOpen() || e.defaultPrevented || document.querySelector("dialog[open], .menu")) return
if (!$("viewer").hidden) return
e.preventDefault()
if (view) closeAgent()
else showPanel(false)
},
true,
)
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible" && chat && panelOpen()) read(false)
})
}
+65 -13
View File
@@ -11,9 +11,10 @@ import { api, get, getText, post, patch, del, follow, why, invoke, openLink } fr
import { markdown } from "./markdown.js"
import { openViewer, showKind } from "./viewer.js"
import { readLocal } from "./explorer.js"
import { menu, ask } from "./popup.js"
import { menu, ask, showLine } from "./popup.js"
import { avatar, modelPicker, effortControl, agentsControl, teamPossible, levelsOf } from "./models.js"
import { readyIds, sending, sent, discard } from "./attach.js"
import { watchActivity, activityChanged, togglePanel, panelOpen, planList } from "./activity.js"
const MODES = ["manual", "edit", "auto", "plan"]
@@ -369,7 +370,10 @@ class Live {
this.status.textContent = t("The connection to the reply was lost: %(why)s", { why: why(err) })
this.status.classList.add("is-error")
this.el.append(h("button", { type: "button", class: "btn btn--sm", text: t("Read again"), onclick: reload }))
} else reload()
} else {
reload()
activityChanged()
}
}
}
@@ -493,16 +497,6 @@ function cardOf(chat, card) {
return el
}
function planList(plan) {
return h(
"ol",
{ class: "plan" },
(plan.phases || []).map((p) =>
h("li", null, h("span", { class: "plan__phase", text: p.title }), h("ul", null, (p.tasks || []).map((task) => h("li", { class: `plan__task is-${task.status}`, text: task.text })))),
),
)
}
// --- Opening a chat --------------------------------------------------------------------------------
function setTitle(title) {
@@ -526,13 +520,38 @@ function renderHead(chat) {
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: chat.mode === m })),
)
tools.append(mode)
if (chat.device_session_id) {
tools.append(
h(
"button",
{ type: "button", class: "pane__btn", "aria-label": t("Continue in a terminal"), title: t("Continue in a terminal"), onclick: () => continueInTerminal(open?.chat || chat) },
icon(ICONS.terminal),
),
)
}
}
const slot = h("span", { class: "head__model" })
tools.append(slot)
chatModels(chat).then((models) => {
if (open?.chat?.id === chat.id) headModel(slot, chat, models)
})
tools.append(h("button", { type: "button", class: "pane__btn", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
tools.append(
h(
"button",
{
type: "button",
class: "pane__btn",
id: "activity-open",
"aria-label": t("Activity"),
title: t("Activity"),
"aria-controls": "activity",
"aria-expanded": String(panelOpen()),
onclick: togglePanel,
},
icon(ICONS.bolt),
),
h("button", { type: "button", class: "pane__btn", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)),
)
}
/** The models this chat may switch to (its data group's), or the account's when that fails. */
@@ -574,6 +593,7 @@ async function headModel(slot, chat, models) {
if (open?.chat?.id === chat.id) {
open.chat = r.chat
headModel(slot, r.chat, models)
activityChanged()
}
} catch (err) {
alertIn(why(err))
@@ -624,12 +644,41 @@ export function chatMenu(anchor, chat) {
const base = ctx.status()?.server
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
const items = [{ label: t("Rename…"), run: () => renameChat(chat) }]
if (chat.kind === "agent" && chat.device_session_id) items.push({ label: t("Continue in a terminal…"), run: () => continueInTerminal(chat) })
if (base) items.push({ label: t("Open in the web interface"), run: () => openLink(`${base}/chat/${encodeURIComponent(chat.id)}`) })
items.push(null, { label: t("Archive chat"), run: () => archiveChat(chat) })
if (mayDelete) items.push({ label: t("Delete chat"), danger: true, run: () => deleteChat(chat) })
menu(anchor, items)
}
/**
* Continue an agent chat in a terminal: the CLI's own session on the device it works on
* (`device_session_id`), opened with `lembas -s <id>` in the chat's folder. Shown to copy rather
* than run: a terminal is not something every desktop opens the same way, and the command is the
* person's to run where they want it. On this computer the first word is the CLI as it is
* started here (`lembas` on PATH, else the app's own copy by its path).
*/
export async function continueInTerminal(chat) {
const session = String(chat.device_session_id || "")
if (!/^[A-Za-z0-9_-]+$/.test(session)) return
const here = onThisComputer(chat)
let word = "lembas"
if (here) word = await invoke("terminal_word").catch(() => "lembas")
const dir = chat.device?.directory || ""
const quoted = /^[A-Za-z0-9/._+:@%=-]+$/.test(dir) ? dir : `'${dir.replace(/'/g, "'\\''")}'`
const code = `${dir ? `cd ${quoted} && ` : ""}${word} -s ${session}`
await showLine({
title: t("Continue in a terminal"),
text: here
? t("Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.")
: t("Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it."),
code,
copy: t("Copy"),
copied: t("Copied"),
close: t("Close"),
})
}
async function renameChat(chat) {
const title = await ask({ title: t("Rename chat"), value: chat.title || "", ok: t("Rename"), cancel: t("Cancel") })
if (!title || title === chat.title) return
@@ -704,6 +753,7 @@ export async function openChat(id) {
if (open?.chat?.id !== id) return
open.chat = r.chat
open.more = r.more
watchActivity(r.chat)
renderHead(r.chat)
clear(list)
if (r.more) list.append(earlier())
@@ -751,6 +801,7 @@ function reload() {
export function closeChat() {
if (open?.live) open.live.stop()
open = null
watchActivity(null)
invoke("set_open_chat", { chat: null })
}
@@ -869,6 +920,7 @@ async function createChat(text, attachments) {
$("new-chat").hidden = true
open = { chat: r.chat, live: null, more: false }
invoke("set_open_chat", { chat: r.chat.id })
watchActivity(r.chat)
renderHead(r.chat)
clear($("messages"))
if (r.message) $("messages").append(userMessage(r.message))
+4
View File
@@ -79,6 +79,10 @@ export const ICONS = {
edit: "M10.75 1.75l3.5 3.5-8.5 8.5h-3.5v-3.5zM3.75 10.87v1.38h1.38l6.62-6.62-1.38-1.38z",
copy: "M5 1.5h8.5V11H5zM6.5 3v6.5H12V3zM2.5 5H4v8.5h6.5V15h-8z",
attach: "M7.25 2.5h1.5v4.75h4.75v1.5H8.75v4.75h-1.5V8.75H2.5v-1.5h4.75z",
bolt: "M9.5 1L3 9.25h4.25L6.5 15 13 6.75H8.75z",
stop: "M4.5 4.5h7v7h-7z",
back: "M10 3.5L5.5 8 10 12.5 11.1 11.4 7.7 8 11.1 4.6z",
terminal: "M1.5 2.5h13v11h-13zM3 4v8h10V4zM4.2 5.4l2.6 2.6-2.6 2.6-1-1L4.8 8 3.2 6.4zM7.5 10h4v1.5h-4z",
search: "M7 2a5 5 0 013.95 8.07l3.24 3.24-1.06 1.06-3.24-3.24A5 5 0 117 2zm0 1.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z",
}
+22 -19
View File
@@ -130,28 +130,31 @@ export function renderServer() {
tree(box, {
rootPath: "/",
list: async (path) => (await get(SERVER_FILES.list(path))).entries || [],
open: async (e) => {
const where = t("Server")
try {
const f = await readServer(e.path)
openViewer({
name: f.name || e.name,
path: f.path || e.path,
where,
text: f.text,
// The server's own kind and language: its table and the viewer's are the same one.
kind: ["markdown", "code", "text", "html"].includes(f.kind) ? f.kind : undefined,
language: f.language || undefined,
withheld: f.withheld,
size: f.size,
})
} catch (err) {
openViewer({ name: e.name, path: e.path, where, error: why(err) })
}
},
open: (e) => openServerFile(e.path, e.name),
})
}
/** A file in the server's tree, in the viewer: from the explorer and from a chat's outputs. */
export async function openServerFile(path, name) {
const where = t("Server")
try {
const f = await readServer(path)
openViewer({
name: f.name || name,
path: f.path || path,
where,
text: f.text,
// The server's own kind and language: its table and the viewer's are the same one.
kind: ["markdown", "code", "text", "html"].includes(f.kind) ? f.kind : undefined,
language: f.language || undefined,
withheld: f.withheld,
size: f.size,
})
} catch (err) {
openViewer({ name, path, where, error: why(err) })
}
}
export function initExplorer() {
for (const pane of document.querySelectorAll(".pane")) {
const toggle = pane.querySelector(".pane__toggle")
+8
View File
@@ -14,6 +14,7 @@ import { initList, refreshList, markCurrent } from "./list.js"
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu } from "./chat.js"
import { initAttach, allowAttach } from "./attach.js"
import { initPalette, openPalette } from "./palette.js"
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
let status = null
let view = "" // "setup" | "app"
@@ -171,6 +172,7 @@ function commands() {
{ label: t("New chat"), run: () => startNew("chat") },
{ label: t("Settings"), run: () => showSettings(true) },
]
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
out.push(
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: () => invoke("link_computer").catch(() => showSettings(true)) },
@@ -191,6 +193,7 @@ function sideOpen(on) {
// --- Settings -----------------------------------------------------------------------------------
function showSettings(on) {
if (on) showPanel(false)
$("settings").hidden = !on
$("main").hidden = on
if (on) {
@@ -241,6 +244,11 @@ async function boot() {
onViewer((on) => {
$("shell").dataset.viewer = String(on)
})
initActivity({
onLayout: (on) => {
$("shell").dataset.activity = String(on)
},
})
for (const b of document.querySelectorAll(".switch button")) b.addEventListener("click", () => setKind(b.dataset.kind))
$("new-chat-btn").prepend(icon(ICONS.plus))
+42
View File
@@ -124,3 +124,45 @@ export function ask({ title, text, value, ok, cancel, danger }) {
} else okBtn.focus()
})
}
/**
* A modal dialog that shows a line to copy: `title`, `text`, the line itself (`code`, shown as
* it is and selectable), and Copy beside Close. Resolves when it is closed.
*/
export function showLine({ title, text, code, copy, copied, close }) {
return new Promise((resolve) => {
const line = h("pre", { class: "dialog__code", tabindex: "0", text: code })
const copyBtn = h("button", { type: "button", class: "btn btn--primary", text: copy })
const closeBtn = h("button", { type: "button", class: "btn", text: close })
const dlg = h(
"dialog",
{ class: "dialog" },
h("div", { class: "dialog__body" }, h("h2", { class: "dialog__title", text: title }), text ? h("p", { class: "muted", text }) : null, line, h("div", { class: "dialog__actions" }, closeBtn, copyBtn)),
)
const done = () => {
dlg.close()
dlg.remove()
resolve()
}
copyBtn.addEventListener("click", () => {
navigator.clipboard
?.writeText(code)
.then(() => (copyBtn.textContent = copied))
.catch(() => {
// No clipboard: the line is selected, for Ctrl+C.
const range = document.createRange()
range.selectNodeContents(line)
getSelection()?.removeAllRanges()
getSelection()?.addRange(range)
})
})
closeBtn.addEventListener("click", done)
dlg.addEventListener("cancel", (e) => {
e.preventDefault()
done()
})
document.body.append(dlg)
dlg.showModal()
copyBtn.focus()
})
}