From a8fcc2724456977972eb741e6115917cf63dd98c Mon Sep 17 00:00:00 2001 From: Regan Bell Date: Tue, 11 Aug 2026 11:01:31 -0700 Subject: [PATCH] web-ui: clock indicator on sessions a cron is scheduled to post into A session that a session-targeted cron will post into (for example a scheduled resume that promised "I'll report when it's green") looked completely idle: the background chip only reflected live background jobs and job-output watches. listSessions now attaches a per-session count of enabled, unarchived crons whose destination targets the session, and sessionBackground lists them (id, title, next fire time) for the inspector panel. The session list shows a clock indicator on those sessions so pending scheduled activity is visible at a glance. --- plugins/web-ui/src/chat.ts | 35 ++++++++++++++-- plugins/web-ui/src/core-bridge.ts | 2 + plugins/web-ui/src/session-list.ts | 10 +++-- plugins/web-ui/src/sessions.ts | 3 +- plugins/web-ui/src/split.ts | 2 +- plugins/web-ui/test/session-list.test.ts | 34 +++++++++++---- src/api/app-sessions.ts | 24 ++++++++++- src/api/app-types.ts | 1 + src/types.ts | 1 + test/sessions-background-activity.test.ts | 50 +++++++++++++++++++++++ 10 files changed, 143 insertions(+), 19 deletions(-) diff --git a/plugins/web-ui/src/chat.ts b/plugins/web-ui/src/chat.ts index de3ec466d..e633b9f65 100644 --- a/plugins/web-ui/src/chat.ts +++ b/plugins/web-ui/src/chat.ts @@ -12,6 +12,7 @@ import { Brain, Check, ChevronRight, + Clock3, Copy, FileImage, FileText, @@ -1538,7 +1539,7 @@ export function createChatSurface( async function refreshBackgroundDetail(): Promise { const id = chatState.sessionId; if (!id) { - bgPanel.detail = { jobs: [], watches: [] }; + bgPanel.detail = { jobs: [], watches: [], crons: [] }; return; } const seq = ++bgPanel.fetchSeq; @@ -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(); } @@ -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; @@ -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`
${bgPanel.error ? html`
${bgPanel.error}
` : nothing} ${!d && bgPanel.loading ? html`
Loading…
` : nothing} ${empty && !bgPanel.error ? html`
Nothing running here anymore.
` : 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}
`; } @@ -1679,6 +1686,26 @@ export function createChatSurface( `; } + function backgroundCronRow(c: SessionBackgroundView["crons"][number]): TemplateResult { + return html` +
+
+ ${icon(Clock3, 13)} + Cron — ${c.title ?? "scheduled task"} + ${c.nextFireAt ? `next fire ${nextFireIn(c.nextFireAt)}` : "paused"} +
+
+ `; + } + + 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(); diff --git a/plugins/web-ui/src/core-bridge.ts b/plugins/web-ui/src/core-bridge.ts index a7cef34db..2ab33e494 100644 --- a/plugins/web-ui/src/core-bridge.ts +++ b/plugins/web-ui/src/core-bridge.ts @@ -61,6 +61,7 @@ export interface CoreSession { awaitingInput?: boolean; backgroundJobs?: number; watches?: number; + crons?: number; forkedFrom?: { sessionId: string; title?: string | null }; forkBoundarySeq?: number; } @@ -137,6 +138,7 @@ export interface SessionBackgroundView { expiresAt: number; lastFiredAt?: number; }>; + crons: Array<{ id: string; title?: string; nextFireAt?: number }>; } export interface SessionBackgroundOutput { diff --git a/plugins/web-ui/src/session-list.ts b/plugins/web-ui/src/session-list.ts index ab3bb21e8..ead35ede0 100644 --- a/plugins/web-ui/src/session-list.ts +++ b/plugins/web-ui/src/session-list.ts @@ -167,17 +167,19 @@ 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 | null): RowIndicators { @@ -185,7 +187,7 @@ export function rowIndicators(s: CoreSession, liveThreads: ReadonlySet | 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), }; } diff --git a/plugins/web-ui/src/sessions.ts b/plugins/web-ui/src/sessions.ts index 738e164f0..55db60e59 100644 --- a/plugins/web-ui/src/sessions.ts +++ b/plugins/web-ui/src/sessions.ts @@ -8,6 +8,7 @@ import { ArchiveRestore, ChevronDown, ChevronRight, + Clock3, Cog, EllipsisVertical, Folder, @@ -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 - } 0 ? icon(Clock3, 11) : nothing}` : nothing }`; diff --git a/plugins/web-ui/src/split.ts b/plugins/web-ui/src/split.ts index 90b8fecd3..f811c25c9 100644 --- a/plugins/web-ui/src/split.ts +++ b/plugins/web-ui/src/split.ts @@ -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 - } 0 ? icon(Clock3, 11) : nothing}` : nothing } diff --git a/plugins/web-ui/test/session-list.test.ts b/plugins/web-ui/test/session-list.test.ts index cf2ad4f99..2c9cab5e4 100644 --- a/plugins/web-ui/test/session-list.test.ts +++ b/plugins/web-ui/test/session-list.test.ts @@ -363,15 +363,23 @@ 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", () => { @@ -379,9 +387,15 @@ test("rowIndicators: background counts flow through backgroundLabel — zero cou 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); }); @@ -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", () => { diff --git a/src/api/app-sessions.ts b/src/api/app-sessions.ts index 6faaf7f23..e40c88df8 100644 --- a/src/api/app-sessions.ts +++ b/src/api/app-sessions.ts @@ -184,7 +184,18 @@ 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(); + 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, @@ -192,6 +203,7 @@ export function createSessionMethods( ...(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)! } : {}), })); }, @@ -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) { diff --git a/src/api/app-types.ts b/src/api/app-types.ts index 679567dc7..8ad649f40 100644 --- a/src/api/app-types.ts +++ b/src/api/app-types.ts @@ -199,6 +199,7 @@ interface SessionBackgroundView { expiresAt: number; lastFiredAt?: number; }>; + crons: Array<{ id: string; title?: string; nextFireAt?: number }>; } interface SessionBackgroundOutput { diff --git a/src/types.ts b/src/types.ts index ab5319b37..d3f74551e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,6 +80,7 @@ export interface Session { awaitingInput?: boolean; backgroundJobs?: number; watches?: number; + crons?: number; } export type EntryType = diff --git a/test/sessions-background-activity.test.ts b/test/sessions-background-activity.test.ts index 50b666cf8..36e1aa98a 100644 --- a/test/sessions-background-activity.test.ts +++ b/test/sessions-background-activity.test.ts @@ -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"); +});