Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions plugins/admin/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2905,6 +2905,30 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.environment-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin: 0 0 18px;
padding: 14px 16px;
border: 1px solid color-mix(in srgb, var(--warn) 42%, var(--border));
border-radius: 10px;
background: color-mix(in srgb, var(--warn) 8%, var(--surface));
}
.environment-notice strong,
.environment-notice p {
display: block;
margin: 0;
}
.environment-notice p {
margin-top: 3px;
color: var(--muted);
font-size: 12px;
}
.environment-notice button {
flex: none;
}
.governance-overview {
margin: 0 0 22px;
padding: 18px 20px;
Expand Down Expand Up @@ -3987,6 +4011,13 @@ <h1>Governance</h1>
<span>Scope</span><strong id="governance-scope-label">Organization</strong>
</div>
</div>
<aside class="environment-notice hidden" id="environment-notice" role="status">
<div>
<strong id="environment-notice-title"></strong>
<p id="environment-notice-detail"></p>
</div>
<button type="button" id="environment-notice-open">Open environment</button>
</aside>
<section class="governance-overview" id="governance-overview" aria-labelledby="governance-overview-title">
<div class="governance-overview-head">
<h2 id="governance-overview-title">Effective state</h2>
Expand Down Expand Up @@ -5717,6 +5748,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
});

let scopeDir = null;
let environmentDir = [];
let scopeDirNote = "Loading scopes…";
async function loadScopeDirectory() {
const r = await api("GET", "/api/scopes");
Expand All @@ -5728,6 +5760,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
}
viewLoadedAt.history = Date.now();
scopeDir = r.data.scopes || [];
environmentDir = r.data.environments || [];

if (SCOPED.has(view) && !urlToState().session) {
const memoryEditor = view === "memory" && !(orgWideView() && urlToState().mem !== "edit");
Expand Down Expand Up @@ -6244,6 +6277,19 @@ <h2 id="governance-review-title">Confirm governance change</h2>
);
}
window.addEventListener("scroll", syncGovernanceSectionNav, { passive: true });
function renderEnvironmentNotice(data) {
const notice = $("environment-notice");
const attachment = data?.environmentAttachment;
notice.classList.toggle("hidden", !attachment);
if (!attachment) return;
const name = attachment.environmentName || shortName(attachment.environmentId);
$("environment-notice-title").textContent = "Uses named environment " + name;
$("environment-notice-detail").textContent =
"Computer files and working memory resolve to this environment. Governance and conversation history remain scoped here.";
$("environment-notice-open").textContent = "Open " + name;
$("environment-notice-open").onclick = () =>
go({ view: "governance", scope: attachment.environmentId, session: null, page: 1 });
}
let governanceReq = 0;
async function loadScope() {
const requestedScope = scope;
Expand All @@ -6259,6 +6305,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
);
return;
}
renderEnvironmentNotice(r.data);
renderGovernanceOverview(r.data);
syncGovernanceSectionNav();
loadedCommandPolicyPresent = r.data.commandPolicy != null;
Expand Down Expand Up @@ -11451,6 +11498,21 @@ <h2 id="governance-review-title">Confirm governance change</h2>
actions: [sortControl],
});
const activityTime = (s) => (scopeSort === "human" ? s.lastConversationActivity || 0 : s.lastActivity || 0);
if (environmentDir.length) {
const environments = denseList(
environmentDir,
(environment) => ({
name: environment.name || shortName(environment.id),
preview: plural(environment.attachedScopes?.length || 0, "attached scope"),
href: stateToUrl({ view: "history", scope: environment.id, historyKind }),
}),
(environment) => selectScope(environment.id),
"No named environments.",
);
root.appendChild(
dataCard("Named environments", "Named computers and working memory that scopes can share.", environments),
);
}
const t = denseList(
activeRows,
(s) => {
Expand Down
13 changes: 13 additions & 0 deletions plugins/admin/test/environments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";

const html = readFileSync(join(import.meta.dirname, "../public/index.html"), "utf8");

test("the admin UI lists named environments and links attachment warnings", () => {
assert.match(html, /Named environments/);
assert.match(html, /id="environment-notice"/);
assert.match(html, /Uses named environment/);
assert.match(html, /scope: attachment\.environmentId/);
});
35 changes: 31 additions & 4 deletions plugins/web-ui/src/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Brain,
Check,
ChevronRight,
Clock3,
Copy,
FileImage,
FileText,
Expand Down Expand Up @@ -1538,7 +1539,7 @@ export function createChatSurface(
async function refreshBackgroundDetail(): Promise<void> {
const id = chatState.sessionId;
if (!id) {
bgPanel.detail = { jobs: [], watches: [] };
bgPanel.detail = { jobs: [], watches: [], crons: [] };
return;
}
const seq = ++bgPanel.fetchSeq;
Expand Down Expand Up @@ -1567,7 +1568,12 @@ export function createChatSurface(
const row = sessionsState.list.find((r) =>
chatState.sessionId ? r.id === chatState.sessionId : r.threadRef === chatState.threadRef,
);
if (row && ((row.backgroundJobs ?? 0) !== d.jobs.length || (row.watches ?? 0) !== d.watches.length)) {
if (
row &&
((row.backgroundJobs ?? 0) !== d.jobs.length ||
(row.watches ?? 0) !== d.watches.length ||
(row.crons ?? 0) !== d.crons.length)
) {
await refreshSessions({ silent: true });
redrawBackgroundPanel();
}
Expand Down Expand Up @@ -1620,7 +1626,7 @@ export function createChatSurface(
const row = conversationBackground(sessionsState.list, chatState.sessionId, chatState.threadRef);
const live =
bgPanel.open && bgPanel.detail
? backgroundLabel(bgPanel.detail.jobs.length, bgPanel.detail.watches.length)
? backgroundLabel(bgPanel.detail.jobs.length, bgPanel.detail.watches.length, bgPanel.detail.crons.length)
: null;
const label = (live ?? row)?.label;
if (!label && !bgPanel.open) return nothing;
Expand All @@ -1643,13 +1649,14 @@ export function createChatSurface(

function backgroundPanelBody(): TemplateResult {
const d = bgPanel.detail;
const empty = d && d.jobs.length === 0 && d.watches.length === 0;
const empty = d && d.jobs.length === 0 && d.watches.length === 0 && d.crons.length === 0;
return html`<div class="bg-panel" role="region" aria-label="Background activity">
${bgPanel.error ? html`<div class="bg-panel-note">${bgPanel.error}</div>` : nothing}
${!d && bgPanel.loading ? html`<div class="bg-panel-note">Loading…</div>` : nothing}
${empty && !bgPanel.error ? html`<div class="bg-panel-note">Nothing running here anymore.</div>` : nothing}
${d ? d.jobs.map((j) => backgroundJobRow(j)) : nothing}
${d ? d.watches.map((w) => backgroundWatchRow(w)) : nothing}
${d ? d.crons.map((c) => backgroundCronRow(c)) : nothing}
</div>`;
}

Expand Down Expand Up @@ -1679,6 +1686,26 @@ export function createChatSurface(
`;
}

function backgroundCronRow(c: SessionBackgroundView["crons"][number]): TemplateResult {
return html`
<div class="bg-row watch">
<div class="bg-row-head static">
${icon(Clock3, 13)}
<span class="bg-row-cmd">Cron — ${c.title ?? "scheduled task"}</span>
<span class="bg-row-meta">${c.nextFireAt ? `next fire ${nextFireIn(c.nextFireAt)}` : "paused"}</span>
</div>
</div>
`;
}

function nextFireIn(at: number): string {
const mins = Math.round((at - Date.now()) / 60_000);
if (mins <= 0) return "due now";
if (mins < 60) return `in ${mins}m`;
if (mins < 1440) return `in ${Math.floor(mins / 60)}h ${String(mins % 60).padStart(2, "0")}m`;
return `in ${Math.floor(mins / 1440)}d`;
}

function backgroundWatchRow(w: SessionBackgroundView["watches"][number]): TemplateResult {
const what = w.pattern ? `output matching /${w.pattern}/` : "any new output";
const note = w.instructions?.trim();
Expand Down
2 changes: 2 additions & 0 deletions plugins/web-ui/src/core-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface CoreSession {
awaitingInput?: boolean;
backgroundJobs?: number;
watches?: number;
crons?: number;
forkedFrom?: { sessionId: string; title?: string | null };
forkBoundarySeq?: number;
}
Expand Down Expand Up @@ -137,6 +138,7 @@ export interface SessionBackgroundView {
expiresAt: number;
lastFiredAt?: number;
}>;
crons: Array<{ id: string; title?: string; nextFireAt?: number }>;
}

export interface SessionBackgroundOutput {
Expand Down
10 changes: 6 additions & 4 deletions plugins/web-ui/src/session-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,25 +167,27 @@ export function applySessionState(
export interface RowIndicators {
working: boolean;
awaiting: boolean;
background: { jobs: number; watches: number; label: string } | null;
background: { jobs: number; watches: number; crons: number; label: string } | null;
}

export function backgroundLabel(
jobs: number,
watches: number,
): { jobs: number; watches: number; label: string } | null {
crons: number,
): { jobs: number; watches: number; crons: number; label: string } | null {
const parts: string[] = [];
if (jobs > 0) parts.push(`${jobs} background job${jobs === 1 ? "" : "s"} running`);
if (watches > 0) parts.push(`${watches} watch${watches === 1 ? "" : "es"} armed`);
return parts.length ? { jobs, watches, label: parts.join(" · ") } : null;
if (crons > 0) parts.push(`${crons} cron${crons === 1 ? "" : "s"} scheduled here`);
return parts.length ? { jobs, watches, crons, label: parts.join(" · ") } : null;
}

export function rowIndicators(s: CoreSession, liveThreads: ReadonlySet<string> | string | null): RowIndicators {
const live = typeof liveThreads === "string" ? new Set([liveThreads]) : (liveThreads ?? new Set<string>());
return {
working: Boolean(s.working) || (Boolean(s.threadRef) && live.has(s.threadRef)),
awaiting: Boolean(s.awaitingInput),
background: backgroundLabel(s.backgroundJobs ?? 0, s.watches ?? 0),
background: backgroundLabel(s.backgroundJobs ?? 0, s.watches ?? 0, s.crons ?? 0),
};
}

Expand Down
3 changes: 2 additions & 1 deletion plugins/web-ui/src/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ArchiveRestore,
ChevronDown,
ChevronRight,
Clock3,
Cog,
EllipsisVertical,
Folder,
Expand Down Expand Up @@ -591,7 +592,7 @@ function statusMarks(s: CoreSession): TemplateResult {
@keydown=${(e: KeyboardEvent) => (e.key === "Enter" || e.key === " ") && openBackgroundInspector(e, s)}
>${ind.background.jobs > 0 ? icon(Cog, 11) : nothing}${
ind.background.watches > 0 ? icon(Binoculars, 11) : nothing
}</span
}${ind.background.crons > 0 ? icon(Clock3, 11) : nothing}</span
>`
: nothing
}`;
Expand Down
4 changes: 2 additions & 2 deletions plugins/web-ui/src/split.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { html, nothing, render, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import { Binoculars, Cog, Expand, Maximize2, Plus, Shrink, X } from "lucide";
import { Binoculars, Clock3, Cog, Expand, Maximize2, Plus, Shrink, X } from "lucide";
import {
createDockview,
type DockviewApi,
Expand Down Expand Up @@ -823,7 +823,7 @@ class PaneTab implements ITabRenderer {
@mouseleave=${(e: Event) => hideTooltip(e.currentTarget as Element)}
>${background.jobs > 0 ? icon(Cog, 11) : nothing}${
background.watches > 0 ? icon(Binoculars, 11) : nothing
}</span
}${background.crons > 0 ? icon(Clock3, 11) : nothing}</span
>`
: nothing
}
Expand Down
34 changes: 27 additions & 7 deletions plugins/web-ui/test/session-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,25 +363,39 @@ test("rowIndicators: awaitingInput maps through", () => {
assert.equal(rowIndicators({ ...saved("1", "web:u:x"), awaitingInput: true }, null).awaiting, true);
});

test("backgroundLabel: jobs and watches fold into one chip with a spoken label", () => {
assert.deepEqual(backgroundLabel(1, 0), { jobs: 1, watches: 0, label: "1 background job running" });
assert.deepEqual(backgroundLabel(2, 1), {
test("backgroundLabel: jobs, watches and crons fold into one chip with a spoken label", () => {
assert.deepEqual(backgroundLabel(1, 0, 0), { jobs: 1, watches: 0, crons: 0, label: "1 background job running" });
assert.deepEqual(backgroundLabel(2, 1, 0), {
jobs: 2,
watches: 1,
crons: 0,
label: "2 background jobs running · 1 watch armed",
});
assert.deepEqual(backgroundLabel(0, 2), { jobs: 0, watches: 2, label: "2 watches armed" });
assert.equal(backgroundLabel(0, 0), null, "nothing running, nothing to say");
assert.deepEqual(backgroundLabel(0, 2, 0), { jobs: 0, watches: 2, crons: 0, label: "2 watches armed" });
assert.deepEqual(backgroundLabel(0, 0, 1), { jobs: 0, watches: 0, crons: 1, label: "1 cron scheduled here" });
assert.deepEqual(backgroundLabel(0, 1, 2), {
jobs: 0,
watches: 1,
crons: 2,
label: "1 watch armed · 2 crons scheduled here",
});
assert.equal(backgroundLabel(0, 0, 0), null, "nothing running, nothing to say");
});

test("rowIndicators: background counts flow through backgroundLabel — zero counts treated as absent", () => {
const both = rowIndicators({ ...saved("1", "web:u:x"), backgroundJobs: 2, watches: 1 }, null);
assert.deepEqual(both.background, {
jobs: 2,
watches: 1,
crons: 0,
label: "2 background jobs running · 1 watch armed",
});
assert.equal(rowIndicators({ ...saved("1", "web:u:x"), backgroundJobs: 0, watches: 0 }, null).background, null);
const cronOnly = rowIndicators({ ...saved("1", "web:u:x"), crons: 3 }, null);
assert.deepEqual(cronOnly.background, { jobs: 0, watches: 0, crons: 3, label: "3 crons scheduled here" });
assert.equal(
rowIndicators({ ...saved("1", "web:u:x"), backgroundJobs: 0, watches: 0, crons: 0 }, null).background,
null,
);
assert.equal(rowIndicators(saved("1", "web:u:x"), null).background, null);
});

Expand All @@ -390,13 +404,19 @@ test("conversationBackground: resolves the mounted conversation by session id",
assert.deepEqual(conversationBackground(list, "2", null), {
jobs: 2,
watches: 1,
crons: 0,
label: "2 background jobs running · 1 watch armed",
});
});

test("conversationBackground: falls back to threadRef while the conversation is still pending adoption", () => {
const list = [{ ...saved("1", "web:u:a"), watches: 1 }];
assert.deepEqual(conversationBackground(list, null, "web:u:a"), { jobs: 0, watches: 1, label: "1 watch armed" });
assert.deepEqual(conversationBackground(list, null, "web:u:a"), {
jobs: 0,
watches: 1,
crons: 0,
label: "1 watch armed",
});
});

test("conversationBackground: null when the conversation has nothing running, or isn't in the list", () => {
Expand Down
Loading
Loading