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
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
2 changes: 1 addition & 1 deletion plugins/web-ui/src/split.ts
Original file line number Diff line number Diff line change
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
24 changes: 22 additions & 2 deletions src/api/app-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,14 +184,26 @@ export function createSessionMethods(
if (m.expiresAt <= now) continue;
watchCounts.set(m.threadRef, (watchCounts.get(m.threadRef) ?? 0) + 1);
}
if (workingThreadRefs.size === 0 && waiting.size === 0 && jobCounts.size === 0 && watchCounts.size === 0)
const cronCounts = new Map<string, number>();
for (const c of await deps.crons.list()) {
if (!c.enabled || c.archived || !c.destination) continue;
cronCounts.set(c.destination.target, (cronCounts.get(c.destination.target) ?? 0) + 1);
}
if (
workingThreadRefs.size === 0 &&
waiting.size === 0 &&
jobCounts.size === 0 &&
watchCounts.size === 0 &&
cronCounts.size === 0
)
return sessions;
return sessions.map((s) => ({
...s,
...(workingThreadRefs.has(s.threadRef) ? { working: true } : {}),
...(waiting.has(s.id) ? { awaitingInput: true } : {}),
...(jobCounts.has(s.threadRef) ? { backgroundJobs: jobCounts.get(s.threadRef)! } : {}),
...(watchCounts.has(s.threadRef) ? { watches: watchCounts.get(s.threadRef)! } : {}),
...(cronCounts.has(s.threadRef) ? { crons: cronCounts.get(s.threadRef)! } : {}),
}));
},

Expand All @@ -216,7 +228,15 @@ export function createSessionMethods(
expiresAt: m.expiresAt,
...(m.lastFiredAt !== undefined ? { lastFiredAt: m.lastFiredAt } : {}),
}));
return { jobs, watches };
const crons = (await deps.crons.list())
.filter((c) => c.enabled && !c.archived && c.destination?.target === session.threadRef)
.sort((a, b) => b.createdAt - a.createdAt)
.map((c) => ({
id: c.id,
...(c.title !== undefined ? { title: c.title } : {}),
...(c.nextFireAt !== undefined ? { nextFireAt: c.nextFireAt } : {}),
}));
return { jobs, watches, crons };
},

async readSessionBackgroundOutput(sessionId, processId, viewer, sinceCursor) {
Expand Down
1 change: 1 addition & 0 deletions src/api/app-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ interface SessionBackgroundView {
expiresAt: number;
lastFiredAt?: number;
}>;
crons: Array<{ id: string; title?: string; nextFireAt?: number }>;
}

interface SessionBackgroundOutput {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface Session {
awaitingInput?: boolean;
backgroundJobs?: number;
watches?: number;
crons?: number;
}

export type EntryType =
Expand Down
50 changes: 50 additions & 0 deletions test/sessions-background-activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,53 @@ test("readSessionBackgroundOutput binds the job to the conversation, not just th
assert.equal(await app.readSessionBackgroundOutput(r2.sessionId!, "p-other", "U2", 0), null);
assert.equal(await app.readSessionBackgroundOutput(r1.sessionId!, "p-ghost", "U1", 0), null);
});

function cronInput(target: string | undefined, action: string) {
return {
ownerScopeId: "personal:U1" as const,
owner: "U1",
createdBy: "U1",
schedule: { everyMs: 60_000 },
action,
...(target ? { destination: { type: "web", target } } : {}),
};
}

test("listSessions counts session-targeted crons — enabled and unarchived only, keyed by destination target", async () => {
const { app, crons } = freshApp();
const here = "web:U1:cronned";
const elsewhere = "web:U1:plain";
await app.turn(dm("watch my PR", here));
await app.turn(dm("nothing scheduled", elsewhere));

await crons.create(cronInput(here, "check the pipeline"));
await crons.create(cronInput(here, "refresh the dashboard"));
const paused = await crons.create(cronInput(here, "poll the checks"));
await crons.setEnabled(paused.id, false);
const archived = await crons.create(cronInput(here, "sweep the queue"));
await crons.update(archived.id, { archived: true });
await crons.create(cronInput(undefined, "cron with no destination"));
await crons.create(cronInput("slack:C123", "cron aimed elsewhere"));

const list = await app.listSessions("U1");
const hereRow = list.find((s) => s.threadRef === here);
const plainRow = list.find((s) => s.threadRef === elsewhere);
assert.equal(hereRow?.crons, 2, "enabled, unarchived crons aimed at this conversation");
assert.equal(plainRow?.crons, undefined, "clean rows carry no zero-count fields");
});

test("sessionBackground lists the session's crons alongside jobs and watches", async () => {
const { app, crons } = freshApp();
const thread = "web:U1:cron-inspect";
const r = await app.turn(dm("schedule it", thread));

const made = await crons.create({ ...cronInput(thread, "watch PR checks"), title: "PR watch" });
const paused = await crons.create(cronInput(thread, "poll the checks"));
await crons.setEnabled(paused.id, false);

const view = await app.sessionBackground(r.sessionId!, "U1");
assert.equal(view?.crons.length, 1);
assert.equal(view?.crons[0]?.id, made.id);
assert.equal(view?.crons[0]?.title, "PR watch");
assert.ok(view?.crons[0]?.nextFireAt, "carries the next fire time");
});
Loading