The activity panel, and continuing an agent chat in a terminal
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:
10 files changed
+1072
-40
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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
@@ -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%; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user