diff --git a/cloudflare/README.md b/cloudflare/README.md index 96e98596..ffd1f705 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -216,6 +216,20 @@ awaits a decision, and any other whose issue, pull request, or quest is still observed, so finished work stays set aside. Retrying a failed planning interview keeps its `plans/` target. +Beside the decisions (below them on a phone), an Agents card shows each job +on the maintainer's projects (`src/inbox/activity.ts`): every target's latest +job while it is queued, running, or waiting, and an ended one while the pull +request it delivered is open and no later job works on that pull request. A +card names the job's target and state (queued, working, runner offline, +waiting on you, account limited, PR open, or stopped), its runner and account, +the agent's latest checkpoint or result summary as escaped text, and how long +ago it wrote it. A working job silent for 15 minutes says "No update since" +instead of guessing. Each card stands in for its job's in-progress row. Raw +output stays behind the card's disclosure: `/app.js` fetches the output route +above only once a maintainer opens it, and shows it as text. An enrolled +project's quest log also charts its tree as the quest map, ringing the quests +those same jobs work on. + A user's projects are those in `github_project_members`. Enrolling a project adds its enroller, and any other user joins by passing the maintainer check on one of its pages, such as its setup page; no extra GitHub calls list diff --git a/cloudflare/src/app.js b/cloudflare/src/app.js index 2aa4ab57..34baf087 100644 --- a/cloudflare/src/app.js +++ b/cloudflare/src/app.js @@ -62,6 +62,48 @@ for (const form of document.querySelectorAll('form[action="/sign-out"]')) { ); } +// A job's raw output loads only when a maintainer opens its disclosure, a page +// at a time, and the server rechecks their authority on every page. It is +// shown as text, never markup. +for (const details of document.querySelectorAll("details[data-output]")) { + const output = details.querySelector("pre"); + const status = details.querySelector("[data-output-status]"); + const more = details.querySelector("[data-output-more]"); + let next = null; + let started = false; + async function load() { + more.disabled = true; + try { + const url = new URL(details.dataset.output, location.href); + if (next) url.searchParams.set("after", `${next.attempt}.${next.seq}`); + const response = await fetch(url, { headers: { Accept: "application/json" } }); + const page = await response.json().catch(() => null); + if (!response.ok) throw new Error(page?.error || "The raw output could not be read."); + output.append(page.output.map((chunk) => chunk.text).join("")); + output.hidden = !output.textContent; + next = page.next; + more.hidden = !next; + status.textContent = !output.textContent + ? "No raw output is stored for this job." + : page.truncated.length + ? "Some output was dropped at the size limit." + : ""; + } catch (error) { + status.textContent = error.message || "Something went wrong."; + // Opening the disclosure again retries a first page that failed. + if (!output.textContent) started = false; + } finally { + more.disabled = false; + } + } + details.addEventListener("toggle", () => { + if (!details.open || started) return; + started = true; + load(); + }); + more.addEventListener("click", load); +} + // Whether the server notifies this browser's subscription for whoever is // signed in. It forgets one when a session signs out, and never hands one to // another user, so the browser's own subscription is not enough to show "on". diff --git a/cloudflare/src/board/pages.tsx b/cloudflare/src/board/pages.tsx index c39389dd..ee599138 100644 --- a/cloudflare/src/board/pages.tsx +++ b/cloudflare/src/board/pages.tsx @@ -2,6 +2,7 @@ import type { Child } from "hono/jsx"; import { raw } from "hono/html"; import { type Doc, entries, rooted } from "quest/core"; import { type Crumb, Difficulty, Marker, Paste, SignIn, Token, type User } from "../layout"; +import { QuestMap } from "../map"; import { render } from "./markdown"; import { type Board, @@ -191,7 +192,11 @@ function Tally(props: { board: Board; show: Status["kind"] | null }) { ); } -export function BoardPage(props: { board: Board; show: Status["kind"] | null }) { +/** + * A project's quest log. An enrolled project's also charts its tree as the + * quest map, highlighting the quests `agents` work on. + */ +export function BoardPage(props: { board: Board; show: Status["kind"] | null; agents?: Map }) { const { board, show } = props; const { project } = board; const recent = board.finished.slice(0, 8); @@ -223,6 +228,8 @@ export function BoardPage(props: { board: Board; show: Status["kind"] | null }) )} + {props.agents && } + {milestones.map(({ milestone, items }) => ( ))} diff --git a/cloudflare/src/inbox/activity.ts b/cloudflare/src/inbox/activity.ts new file mode 100644 index 00000000..f8a6964b --- /dev/null +++ b/cloudflare/src/inbox/activity.ts @@ -0,0 +1,131 @@ +// Agent cards: what each job on a maintainer's projects is doing, told by the +// agent's own checkpoint summaries. Summaries are untrusted agent text, shown +// as escaped text and never read as an answer. Raw output is not part of a +// card; it loads behind a per-job disclosure that rechecks maintainer authority. + +import { parse } from "quest/core"; +import type { Enrollment } from "../github/service"; +import { type Item, type JobView, type ProjectView, type Runners, entities, fallback } from "./model"; + +/** How long a working job may go without a summary before its card says so, rather than guessing. */ +export const QUIET = 15 * 60_000; + +/** + * Where a job stands: waiting for a runner, working, on a runner that went + * offline, waiting on a maintainer's answer, paused on a limited account, or + * ended with the pull request it delivered still open, either finished or stopped. + */ +export type Phase = "queued" | "working" | "offline" | "waiting" | "limited" | "pull" | "stopped"; + +export const PHASES: Record = { + queued: "Queued", + working: "Working", + offline: "Runner offline", + waiting: "Waiting on you", + limited: "Account limited", + pull: "PR open", + stopped: "Stopped", +}; + +export type Card = { + project: Enrollment; + job: JobView; + /** The target the job works on: its issue, pull request, or quest. */ + title: string; + href: string; + /** The quest the job works on, which the quest map highlights. */ + quest: string | null; + phase: Phase; + runner: string | null; + account: string | null; + /** The pull request the job delivered. */ + pull: { number: number; href: string } | null; + /** When the agent last wrote a summary, or when the job was requested. */ + at: number; + /** A working job silent for longer than `QUIET`. */ + quiet: boolean; + /** The inbox item for the job's target, where its thread and stop control live. */ + item: Item | null; +}; + +export function phase(job: JobView, runners: Runners): Phase { + if (job.state === "queued") return "queued"; + if (job.state === "running") { + const runner = runners.find((r) => r.id === job.runner); + return job.lease !== null || !runner?.online ? "offline" : "working"; + } + if (job.state === "waiting") { + const question = job.questions.find((q) => q.attempt === job.attempt && q.approvedAt === null); + return question?.kind === "account" && question.answer === null ? "limited" : "waiting"; + } + return job.state === "completed" ? "pull" : "stopped"; +} + +// Cards that wait on a maintainer come first, then trouble, then work under way. +const ORDER: Phase[] = ["waiting", "limited", "offline", "working", "queued", "stopped", "pull"]; + +/** The cards for each project's jobs, with `items` from the same inbox load. */ +export function cards( + views: ProjectView[], + jobs: JobView[][], + runners: Map, + items: Item[], + now: number, +): Card[] { + return views + .flatMap((view, i) => { + const { project, state } = view; + const found = entities(project, state); + const web = `https://github.com/${project.owner}/${project.name}`; + return jobs[i]!.map((job): Card => { + const own = runners.get(job.actor) ?? []; + const runner = own.find((r) => r.id === job.runner); + const entity = found.get(job.target) ?? fallback(project, job.target); + const kind = entity.kind; + // A pull request on a quest's branch works on that quest, so the map keeps it highlighted. + const number = kind === "pull" ? Number(job.target.split("/")[1]) : null; + const quest = + kind === "quest" + ? job.target + : (state?.project.changes.find((change) => change.number === number)?.quest ?? null); + // A claimed quest is no longer an observed entity, so its title comes from the tree. + const document = kind === "quest" ? state?.project.documents.find((d) => d.path === quest) : undefined; + const summaries = job.events.filter((e) => e.kind === "summary" || e.kind === "result"); + const at = summaries.at(-1)?.at ?? job.createdAt; + const current = phase(job, own); + return { + project, + job, + title: document ? (parse(document.path, document.content).title?.text ?? quest!) : entity.title, + href: entity.href, + quest, + phase: current, + runner: runner?.name ?? null, + account: runner?.accounts.find((a) => a.id === job.account)?.label ?? null, + pull: job.pull === null ? null : { number: job.pull, href: `${web}/pull/${job.pull}` }, + at, + quiet: current === "working" && now - at > QUIET, + item: items.find((it) => it.project.id === project.id && it.target === job.target) ?? null, + }; + }); + }) + .sort((a, b) => ORDER.indexOf(a.phase) - ORDER.indexOf(b.phase) || b.at - a.at); +} + +/** How long ago `at` was, roughly: just now, minutes, hours, then days. */ +export function ago(now: number, at: number): string { + const minutes = Math.max(0, Math.round((now - at) / 60_000)); + if (minutes === 0) return "just now"; + const [count, unit] = + minutes < 60 + ? [minutes, "minute"] + : minutes < 48 * 60 + ? [Math.round(minutes / 60), "hour"] + : [Math.round(minutes / 1440), "day"]; + return `${count} ${unit}${count === 1 ? "" : "s"} ago`; +} + +/** The quest map's highlights: what the agent on each quest is doing, by the quest's path. */ +export function highlights(list: Card[]) { + return new Map(list.flatMap((card) => (card.quest ? [[card.quest, PHASES[card.phase]] as const] : []))); +} diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts index a963c110..fb499c76 100644 --- a/cloudflare/src/inbox/model.ts +++ b/cloudflare/src/inbox/model.ts @@ -112,7 +112,8 @@ function held(entity: Pick) { return entity.kind === "pull" && !!entity.activity?.data && owned(entity.activity.data as Pull); } -function fallback(project: Enrollment, target: string): Entity { +/** What an item is called and links to when nothing observed names its target. */ +export function fallback(project: Enrollment, target: string): Entity { const web = `https://github.com/${project.owner}/${project.name}`; const kind = kindOf(target); const number = target.split("/")[1]; @@ -264,22 +265,36 @@ export async function load(env: Env, user: string) { }), ); const projects = views.filter((view) => view !== null); - const items = await collect( - env.DB, - user, - projects.filter((view) => !view.error), - ); - // Jobs run on their requester's runners, so presence comes from each requester's hub. - const actors = [...new Set(items.flatMap((item) => (item.job ? [item.job.actor] : [])))]; - const runners = new Map( + const readable = projects.filter((view) => !view.error); + const [items, activity] = await Promise.all([ + collect(env.DB, user, readable), + Promise.all(readable.map((view) => store.listActivityJobs(env.DB, view.project.id, openPulls(view)))), + ]); + const actors = [ + ...items.flatMap((item) => (item.job ? [item.job.actor] : [])), + ...activity.flat().map((j) => j.actor), + ]; + return { projects, readable, items, activity, runners: await presence(env, actors) }; +} + +/** The open pull requests the project's observations list. */ +export function openPulls(view: ProjectView) { + return [...entities(view.project, view.state).keys()].flatMap((target) => { + const number = target.match(/^pulls\/(\d+)$/)?.[1]; + return number ? [Number(number)] : []; + }); +} + +/** Each job requester's runners, with presence from their hub: jobs run on their requester's runners. */ +export async function presence(env: Env, actors: Iterable) { + return new Map( await Promise.all( - actors.map( + [...new Set(actors)].map( async (actor) => [actor, await store.listRunners(env.DB, actor, new Set(await hub(env, actor).online()))] as const, ), ), ); - return { projects, items, runners }; } /** Record that the maintainer saw an item as it is now. */ diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx index 007f441d..053517cc 100644 --- a/cloudflare/src/inbox/page.tsx +++ b/cloudflare/src/inbox/page.tsx @@ -3,6 +3,7 @@ import { type Prompt, parsePrompt } from "../jobs/prompt"; import { type Done, PUBLICATION_REPLY, inboxURL } from "../jobs/routes"; import { Marker, Notifications } from "../layout"; import { projectURL } from "../projects"; +import { type Card, PHASES, ago } from "./activity"; import type { Item, ProjectView, QuestionView, Runners } from "./model"; // The decision inbox, phone first: one active decision thread, then what is @@ -21,6 +22,9 @@ export type InboxProps = { working: Item[]; skipped: Item[]; runners: Map; + /** What each agent job is doing, beside the decisions. */ + cards: Card[]; + now: number; /** The VAPID public key, or null where notifications are not configured. */ notifications: string | null; }; @@ -170,7 +174,7 @@ function Approval(props: { item: Item }) { ); } -function jobsURL(item: Item) { +function jobsURL(item: Pick) { return `${projectURL(item.project.owner, item.project.name)}/jobs`; } @@ -533,76 +537,162 @@ function Project(props: { view: ProjectView }) { ); } +const workflows = { plan: "Planning", start: "Implementing", iterate: "Iterating", bootstrap: "Adding a devShell" }; + +/** + * One agent job: its target, state, runner and account, and the agent's latest + * summary, as escaped text. Raw output loads only when a maintainer opens its + * disclosure, and the server rechecks their authority on every read. + */ +function AgentCard(props: { card: Card; runners: Runners; user: string; now: number }) { + const { card, now } = props; + const { job, item } = card; + // Another maintainer's answer waits on the requester's approval, not on this viewer. + const label = card.phase === "waiting" && item?.need === null ? "Awaiting approval" : PHASES[card.phase]; + const status = item && progress(item, props.runners, props.user); + const when = ago(now, card.at); + const output = `${jobsURL(card)}/${job.id}/output`; + return ( +
  • +

    + {card.project.owner}/{card.project.name} · {label} +

    +

    + {card.title} +

    + + {status &&

    {status}

    } + {job.summary ? ( +

    {job.summary}

    + ) : ( +

    The agent has not written a summary yet.

    + )} +

    + +

    +
    + Raw output +

    + Only the project's current maintainers can read it. It loads when opened. +

    + + + +
    +
  • + ); +} + export function InboxPage(props: InboxProps) { const { active } = props; const view = active && props.projects.find((v) => v.project.id === active.project.id); const unread = props.decisions.filter((item) => item.unread).length; const next = props.decisions.filter((item) => item !== active); + // Agent cards stand in for the in-progress rows of the jobs they show. + const carded = new Set(props.cards.map((card) => card.item)); + const working = props.working.filter((item) => !carded.has(item)); return (
    -

    Decisions

    -

    - {props.decisions.length - ? `${props.decisions.length} waiting on you${unread ? `, ${unread} new` : ""}.` - : "Nothing needs a decision right now."} -

    - {props.done && Object.hasOwn(recorded, props.done) && ( -

    - {recorded[props.done as Done]} +

    +

    Decisions

    +

    + {props.decisions.length + ? `${props.decisions.length} waiting on you${unread ? `, ${unread} new` : ""}.` + : "Nothing needs a decision right now."}

    - )} - - {props.missing && ( -

    - That decision is no longer open. It may have been answered or changed on GitHub. -

    - )} - {active && ( - - )} - {next.length > 0 && ( -
    -

    Up next

    - -
    - )} - {props.working.length > 0 && ( -
    -

    In progress

    - -
    - )} -
    -

    Projects

    - {props.projects.length ? ( -
      - {props.projects.map((view) => ( - + {props.done && Object.hasOwn(recorded, props.done) && ( +

      + {recorded[props.done as Done]} +

      + )} + + {props.missing && ( +

      + That decision is no longer open. It may have been answered or changed on GitHub. +

      + )} + {active && ( + + )} + {next.length > 0 && ( +
      +

      Up next

      + +
      + )} +
    + +
    + {working.length > 0 && ( +
    +

    In progress

    + +
    + )} +
    +

    Projects

    + {props.projects.length ? ( +
      + {props.projects.map((view) => ( + + ))} +
    + ) : ( +

    + No enrolled projects yet. Enroll a project, or open one a + co-maintainer enrolled, to get its issues, pull requests, and ready quests here. +

    + )} +

    + Lists enrolled projects you maintain, once you open each one's setup page. Runners and accounts + are set up with quest runner; your runners shows them and + the order jobs use your accounts in.

    +
    + {props.skipped.length > 0 && ( +
    + Skipped and finished ({props.skipped.length}) + +
    )} -

    - Lists enrolled projects you maintain, once you open each one's setup page. Runners and accounts are - set up with quest runner; your runners shows them and the order - jobs use your accounts in. -

    -
    - {props.skipped.length > 0 && ( -
    - Skipped and finished ({props.skipped.length}) - -
    - )} + ); } diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx index 25047206..ede66a64 100644 --- a/cloudflare/src/index.tsx +++ b/cloudflare/src/index.tsx @@ -13,11 +13,12 @@ import { type Enrollment, GitHubError, GitHubService } from "./github/service"; import { readActivity } from "./github/state"; import { ProjectsPage, ProjectSetupPage, ProjectStopControls, ProjectStatePage, projectURL } from "./projects"; import { githubWebhooks } from "./github/webhook"; +import { cards, highlights } from "./inbox/activity"; import * as inbox from "./inbox/model"; import { InboxPage } from "./inbox/page"; import { purge, retain } from "./jobs/output"; import { BootstrapRequired, fromForm, hub, inboxURL, jobs, nextURL, resume, stop } from "./jobs/routes"; -import { JobError, active, listRunners, moveAccount, registerRunner } from "./jobs/store"; +import { JobError, active, listActivityJobs, listRunners, moveAccount, registerRunner } from "./jobs/store"; import { Layout, type Page, type User } from "./layout"; import { RunnersPage, connectRequest } from "./runners"; import * as push from "./push/notify"; @@ -270,7 +271,8 @@ app.get("/inbox", async (c) => { const session = await createAuth(c.env).api.getSession({ headers: c.req.raw.headers }); if (!session) return c.redirect("/sign-in?next=/inbox", 303); const user = session.user.id; - const { projects, items, runners } = await inbox.load(c.env, user); + const { projects, readable, items, activity, runners } = await inbox.load(c.env, user); + const now = Date.now(); const { decisions, working, skipped } = inbox.arrange(items); const project = c.req.query("project"); const target = c.req.query("item"); @@ -294,6 +296,8 @@ app.get("/inbox", async (c) => { working={working} skipped={skipped} runners={runners} + cards={cards(readable, activity, runners, items, now)} + now={now} notifications={push.keys(c.env)?.publicKey ?? null} />, ); @@ -343,14 +347,22 @@ app.post("/runners/accounts", async (c) => { return c.redirect("/runners", 303); }); app.get("/projects/github/:owner/:repository/board", async (c) => { - const { state } = await liveProject(c); + const { repo, state } = await liveProject(c); if (!state) return c.redirect(`${projectURL(c.req.param("owner"), c.req.param("repository"))}/state`, 303); + // The map highlights the quests agents work on, from the same jobs as the inbox's cards. + const view = { project: repo, state, error: null }; + const jobs = await listActivityJobs(c.env.DB, repo.id, inbox.openPulls(view)); + const runners = await inbox.presence( + c.env, + jobs.map((job) => job.actor), + ); return page( c, { title: `${state.project.title} quest log`, description: "Pinned repository quest tree." }, kind === c.req.query("show"))?.[0] ?? null} + agents={highlights(cards([view], [jobs], runners, [], Date.now()))} />, ); }); diff --git a/cloudflare/src/jobs/store.ts b/cloudflare/src/jobs/store.ts index 4bbdd2a7..dc75b2ca 100644 --- a/cloudflare/src/jobs/store.ts +++ b/cloudflare/src/jobs/store.ts @@ -758,6 +758,25 @@ export async function listInboxJobs(db: D1Database, repository: number, observed return (await Promise.all(rows.results.map((row) => readJob(db, row.id)))).filter((job) => job !== null); } +/** + * The jobs agent cards show, newest first: each target's latest job while it + * is queued, running, or waiting, or once ended while the pull request it + * delivered is one of the `open` ones and no later job works on that pull request. + */ +export async function listActivityJobs(db: D1Database, repository: number, open: number[]) { + const rows = await db + .prepare( + `SELECT id FROM jobs j WHERE repository=?1 + AND NOT EXISTS(SELECT 1 FROM jobs n WHERE n.repository=?1 AND n.target=j.target AND (n.createdAt>j.createdAt OR (n.createdAt=j.createdAt AND n.idj.id AND n.createdAt>=j.createdAt))) + ORDER BY createdAt DESC,id`, + ) + .bind(repository, JSON.stringify(open)) + .all<{ id: string }>(); + return (await Promise.all(rows.results.map((row) => readJob(db, row.id)))).filter((job) => job !== null); +} + type Pin = { runner: string; account: string }; /** The account an account decision chose. */ diff --git a/cloudflare/src/map.tsx b/cloudflare/src/map.tsx index ce687949..01474446 100644 --- a/cloudflare/src/map.tsx +++ b/cloudflare/src/map.tsx @@ -5,6 +5,7 @@ import { type Progress, type Quest, type Status, + boardHref, milestoneAnchor, flatten, } from "./board/model"; @@ -71,7 +72,7 @@ export function chart(board: Board): Region[] { }, ]; }); - return { milestone, href: `/repos/${board.project.name}#${milestoneAnchor(milestone)}`, paths }; + return { milestone, href: `${boardHref(board.project)}#${milestoneAnchor(milestone)}`, paths }; }); } @@ -99,28 +100,35 @@ function tooltip(quest: Quest): string { return `${line}\nRequired: ${status.requires.map((ref) => ref.title).join(", ")}`; } -function Stop(props: { quest: Quest }) { - const { quest } = props; +/** A waypoint, and what its agent is doing when one works on it. */ +function Stop(props: { quest: Quest; agent: string | undefined }) { + const { quest, agent } = props; const kind = quest.status.kind; const size = quest.size ? ` ql-${quest.size.toLowerCase()}` : ""; - // Screen readers hear the title first, then the size and status. - const name = `${quest.title}, size ${quest.size ?? "unknown"}, ${text(quest.status)}`; + // Screen readers hear the title first, then the size, status, and agent. + const name = `${quest.title}, size ${quest.size ?? "unknown"}, ${text(quest.status)}${agent ? `, agent: ${agent}` : ""}`; return ( -
  • - +
  • + {quest.size ?? "?"} {quest.title} {text(quest.status)} + {agent && Agent: {agent}}
  • ); } -export function QuestMap(props: { board: Board }) { +/** The map; `agents` highlights the quests agents work on, saying what each is doing. */ +export function QuestMap(props: { board: Board; agents?: Map }) { const { board } = props; const web = board.project.web; const regions = chart(board); @@ -152,7 +160,7 @@ export function QuestMap(props: { board: Board }) {

      {path.waypoints.map((quest) => ( - + ))}
    @@ -170,6 +178,7 @@ export function QuestMap(props: { board: Board }) {

    A solid waypoint is ready or in review; a dashed one is blocked.{" "} + {props.agents && "A ringed one has an agent on it. "} {web && ( <> Charted from quest/ on{" "} diff --git a/cloudflare/src/site.css b/cloudflare/src/site.css index e9a28f70..c8e61e17 100644 --- a/cloudflare/src/site.css +++ b/cloudflare/src/site.css @@ -636,6 +636,19 @@ pre { box-shadow: none; } +/* A quest an agent works on wears a ring in the accent colour. */ +.waypoint-agent .waypoint-node::after { + content: ""; + position: absolute; + inset: -0.05rem; + border: 2px solid var(--ql-accent); + border-radius: 50%; +} + +.waypoint-agent-status { + color: var(--ql-accent); +} + .waypoint-size { position: relative; font-family: var(--ql-font-display); @@ -1225,13 +1238,71 @@ a.entry-title:hover { justify-self: start; } -/* The decision inbox: one active decision thread, phone first. */ +/* The decision inbox: one active decision thread, phone first. On a wide + screen the agent cards sit beside the decisions; on a phone they follow them. */ .inbox { - max-width: 46rem; + display: grid; + grid-template-columns: minmax(0, 46rem); + column-gap: 2.5rem; + align-items: start; } -.inbox > section { +@media (min-width: 72rem) { + .inbox { + grid-template-columns: minmax(0, 46rem) minmax(18rem, 1fr); + /* Extra height from a long card list goes below the decisions. */ + grid-template-rows: auto 1fr; + } + .agents { + grid-column: 2; + grid-row: 1 / span 2; + } +} +.inbox-main > section, +.inbox-rest > section, +.agents { margin-block: 1.75rem; } +.agents > h2 { + margin-top: 0; +} +.agent { + display: grid; + gap: 0.4rem; + padding: 0.8rem 0.9rem; + background: var(--ql-bg); + border: 1px solid var(--ql-rule); + border-left: 2px solid var(--ql-rule-strong); + border-radius: 4px; +} +.agent-waiting, +.agent-limited, +.agent-offline { + border-left-color: var(--ql-accent); +} +.agent h3 { + margin: 0; + font-size: 1rem; + overflow-wrap: anywhere; +} +.agent p { + margin: 0; +} +/* Agent text keeps its line breaks but stays escaped text. */ +.agent-summary { + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.agent-output summary { + cursor: pointer; + font-size: 0.92rem; +} +.agent-output pre { + max-height: 24rem; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: 0.8rem; +} .decision { display: grid; gap: 0.75rem; diff --git a/cloudflare/test/inbox.test.ts b/cloudflare/test/inbox.test.ts index 12058b8a..4b7a6253 100644 --- a/cloudflare/test/inbox.test.ts +++ b/cloudflare/test/inbox.test.ts @@ -2,7 +2,9 @@ import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test" import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { app } from "../src/index"; +import { QUIET, cards } from "../src/inbox/activity"; import { questHref } from "../src/inbox/model"; +import * as output from "../src/jobs/output"; import * as store from "../src/jobs/store"; import { privateKey } from "./github-key"; import { identity, oauth, origin } from "./login-fixtures"; @@ -71,6 +73,10 @@ async function reads(who = user) { function thread(html: string) { return html.match(/

    /)?.[0] ?? ""; } +/** The agent cards beside the decisions. */ +function agents(html: string) { + return html.match(/