From f3066596200072199190e0be55c1a49d3076a0ce Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 23:03:59 -0700 Subject: [PATCH 1/3] chore(quest): claim quest/m0/kix/activity.md Quest-Claim: b24cc0e8a8f705f1 From 86daad70bc298d973cb6b8b26744c8d4caf13b64 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 23:21:52 -0700 Subject: [PATCH 2/3] feat(kix): show what agents are working on as summary cards The inbox gets an Agents column beside the decisions (below them on a phone): one card per job with its target, state, runner and account, the agent's latest checkpoint summary as escaped text, and how long ago it was written. A working job quiet for 15 minutes says "No update since" instead of guessing. Raw output loads only when a maintainer opens a card's disclosure, through the existing output route that rechecks maintainer authority on every request. An enrolled project's quest log charts its tree as the quest map and rings the quests those jobs work on. Co-Authored-By: Claude Opus 5.5 --- cloudflare/README.md | 14 +++ cloudflare/src/app.js | 42 +++++++ cloudflare/src/board/pages.tsx | 9 +- cloudflare/src/inbox/activity.ts | 125 +++++++++++++++++++ cloudflare/src/inbox/model.ts | 37 ++++-- cloudflare/src/inbox/page.tsx | 208 ++++++++++++++++++++++--------- cloudflare/src/index.tsx | 18 ++- cloudflare/src/jobs/store.ts | 19 +++ cloudflare/src/map.tsx | 27 ++-- cloudflare/src/site.css | 77 +++++++++++- cloudflare/test/inbox.test.ts | 142 ++++++++++++++++++++- quest/m0/kix/README.md | 1 - quest/m0/kix/activity.md | 30 ----- quest/m0/kix/execution.md | 1 - 14 files changed, 630 insertions(+), 120 deletions(-) create mode 100644 cloudflare/src/inbox/activity.ts delete mode 100644 quest/m0/kix/activity.md 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..faf0eb51 --- /dev/null +++ b/cloudflare/src/inbox/activity.ts @@ -0,0 +1,125 @@ +// 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 quest = entity.kind === "quest" ? job.target : null; + // A claimed quest is no longer an observed entity, so its title comes from the tree. + const document = 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..d4ae70b6 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(/