diff --git a/DEVLOG.md b/DEVLOG.md index 08b262c1..fba71907 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -13369,3 +13369,65 @@ the full Vitest gate passed 1,320 tests with one intentional live-connector skip; the production build succeeded; the affected mobile and multi-workspace specs passed 13 journeys; and the complete serial Playwright gate passed all 47 desktop, mobile, accessibility, and application journeys. + +--- + +## 2026-07-15 — Mission Control agent surface scroll repair + +Reproduced the fleet regression against live data at a 900×650 desktop +viewport. The `/agents` and `/agents/[id]` route shells opted into clipped +overflow but did not establish the flex-column parent required by their nested +scroll regions, leaving lower profiles and profile controls outside the +reachable viewport. + +Restored the intended flex-column scroll contract on both routes and added +stable fleet/profile scroll-region hooks. The Mission Control lifecycle E2E +journey now constrains the viewport and proves that both surfaces overflow and +can scroll before continuing through profile binding and removal. + +Verification: live-data browser checks confirmed wheel scrolling and clickable +top/bottom actions without mutating production records; lint and typecheck +passed (existing repository warnings only); the isolated Vitest gate passed +1,320 tests with one intentional live-connector skip; and the production-built +Mission Control Playwright suite passed both journeys. + +--- + +## 2026-07-15 — Mission Control attention surfaces + +Audited Mission Control, the cross-workspace Inbox, Activity, and top-bar +controls against live data at the operator's 1164×698 viewport. The Activity +pill's numeric keyboard hint read like a stuck notification count; the bell and +help controls were inert; assigned work lacked enough context to triage in +place; raw action-request events flooded Activity; and the capacity metric +reported agent presence rather than concurrent run slots. + +Replaced the fake Activity count with an accessible shortcut description and +wired the help control into the shared keyboard guide. The global bell now uses +the same persisted notification and since-visit inbox lifecycle as workspace +alerts, exposes one combined unread count, and opens a responsive right-side +attention panel with assignments, operational alerts, recent activity, and one +mark-all-read action. + +The global Inbox now excludes terminal assignments and provides a persistent +issue inspector with description, status, priority, project, labels, agent/run +state, latest comment, and canonical workspace links. Global Activity now uses +the workspace timeline interpreter, turns raw events into human-readable rows, +groups repeated action-request/watchdog updates by subject, and provides a +source inspector. Mission Control reports active runs against configured +finite slots while naming unlimited-capacity agents explicitly. + +Verification: live-data browser comparison confirmed all four updated surfaces +and both top-bar controls; lint and typecheck passed (existing repository +warnings only); the complete Vitest gate passed 1,323 tests with one intentional +live-connector skip; focused notification, event grouping, and capacity +coverage passed 12/12; and the production-built multi-workspace Playwright +journeys passed 8/8 after making the empty-data assertion deterministic. + +Follow-up: the global top bar's breadcrumb trail was visually suggestive but +rendered as inert text. Global pages now declare explicit parent destinations, +the shared shell renders a semantic breadcrumb landmark with keyboard-focusable +parent links, and the current page remains non-interactive with `aria-current`. +The live-data 1164×698 comparison preserved the existing visual hierarchy while +proving Activity → Mission Control navigation; a fresh production build and the +focused Playwright journey passed. diff --git a/src/app/(app)/activity/page.tsx b/src/app/(app)/activity/page.tsx index 1115ac78..cdbbd0b1 100644 --- a/src/app/(app)/activity/page.tsx +++ b/src/app/(app)/activity/page.tsx @@ -17,7 +17,7 @@ export default async function GlobalActivityPage() { user={data.user} workspaces={data.workspaces} activePath="/activity" - crumbs={["Forge", "Activity"]} + crumbs={[{ label: "Forge", href: "/" }, { label: "Activity" }]} title="Activity" subtitle="Live run & event feed across all your workspaces" eyebrow="Across workspaces" diff --git a/src/app/(app)/agents/[id]/page.tsx b/src/app/(app)/agents/[id]/page.tsx index 44659e07..81f70943 100644 --- a/src/app/(app)/agents/[id]/page.tsx +++ b/src/app/(app)/agents/[id]/page.tsx @@ -17,9 +17,14 @@ export default async function AgentFleetDetailPage({ user={data.user} workspaces={data.workspaces} activePath="/agents" - crumbs={["Forge", "Mission Control", "Agents", "Profile"]} + crumbs={[ + { label: "Forge", href: "/" }, + { label: "Mission Control", href: "/" }, + { label: "Agents", href: "/agents" }, + { label: "Profile" }, + ]} scope="control" - contentClass="overflow-hidden" + contentClass="flex flex-col overflow-hidden" > {children} + ); } diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index a78e9f7b..e8fefef5 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -18,7 +18,7 @@ export default async function MissionControlPage() { user={data.user} workspaces={data.workspaces} activePath="/" - crumbs={["Forge", "Mission Control"]} + crumbs={[{ label: "Forge", href: "/" }, { label: "Mission Control" }]} title="Mission Control" subtitle="Across all workspaces" eyebrow="Home" diff --git a/src/app/(app)/settings/agents/[id]/agent-detail-content.tsx b/src/app/(app)/settings/agents/[id]/agent-detail-content.tsx index 4f4277f7..17f3b095 100644 --- a/src/app/(app)/settings/agents/[id]/agent-detail-content.tsx +++ b/src/app/(app)/settings/agents/[id]/agent-detail-content.tsx @@ -328,7 +328,7 @@ export function AgentDetailContent({ "Identity-level agent profile — applies in every workspace you bind it to." } /> -
+
-
+
{[ diff --git a/src/app/(app)/w/[slug]/layout.tsx b/src/app/(app)/w/[slug]/layout.tsx index 0d7a3b65..52382539 100644 --- a/src/app/(app)/w/[slug]/layout.tsx +++ b/src/app/(app)/w/[slug]/layout.tsx @@ -5,7 +5,6 @@ import { Sidebar } from "@/components/sidebar"; import { TopBar } from "@/components/top-bar"; import { CommandPalette } from "@/components/command-palette"; import { QuickCreate } from "@/components/quick-create"; -import { KeyboardHelp } from "@/components/keyboard-help"; import { RealtimeProvider } from "@/components/realtime-provider"; import RealtimeToaster from "@/components/realtime-toaster"; import { TrpcProvider } from "@/lib/trpc-provider"; @@ -150,7 +149,6 @@ export default async function WorkspaceShellLayout({
- diff --git a/src/app/(app)/whats-new/page.tsx b/src/app/(app)/whats-new/page.tsx index 54fe655a..482bfcbd 100644 --- a/src/app/(app)/whats-new/page.tsx +++ b/src/app/(app)/whats-new/page.tsx @@ -18,7 +18,7 @@ export default async function WhatsNewPage() { user={data.user} workspaces={data.workspaces} activePath="/whats-new" - crumbs={["Forge", "What's New"]} + crumbs={[{ label: "Forge", href: "/" }, { label: "What's New" }]} title="What's New" subtitle="Shipped changes, newest first" eyebrow="Release notes" diff --git a/src/components/global-shell/global-activity.tsx b/src/components/global-shell/global-activity.tsx index af8e090f..998ca2db 100644 --- a/src/components/global-shell/global-activity.tsx +++ b/src/components/global-shell/global-activity.tsx @@ -1,23 +1,26 @@ "use client"; -import { Activity as ActivityIcon } from "lucide-react"; +import { useState } from "react"; +import Link from "next/link"; +import { Activity as ActivityIcon, ArrowUpRight, CircleDot } from "lucide-react"; import { Spinner, EmptyState } from "@/components/ui"; -import { activityActorName, activityActorOwnerTitle } from "@/lib/activity-actor"; import { trpc } from "@/lib/trpc"; -import { relativeTime } from "@/lib/utils"; +import { cn, relativeTime } from "@/lib/utils"; import { WsChip } from "./mission-control-home"; /** - * Global activity feed — recent events across every workspace the user - * belongs to (the full-page counterpart to the Activity dock). Read-only; - * each row carries its workspace chip and the acting human/agent. + * Cross-workspace activity with a compact stream and a persistent inspector. + * The server owns event interpretation so this page, Mission Control, and + * future notification surfaces all use the same human-readable copy + links. */ export function GlobalActivity() { const activity = trpc.global.activity.useQuery(); const rows = activity.data ?? []; + const [selectedId, setSelectedId] = useState(null); + const selected = rows.find((row) => row.id === selectedId) ?? rows[0] ?? null; return ( -
+
{activity.isLoading ? (
@@ -29,35 +32,103 @@ export function GlobalActivity() { description="Runs, comments, and decisions across your workspaces appear here as they happen." /> ) : ( -
- {rows.map((e, i) => { - const actor = activityActorName(e); - const actorTitle = activityActorOwnerTitle(e); - const kind = e.kind.split(".").slice(1).join(".") || e.kind; - return ( -
0 ? "border-t border-border/60" : ""}`} - > - - - {kind} - - - - {actor} - {" "} - · {e.subjectType} - - +
+
+
+

Recent changes

+

+ Repeated watchdog updates are grouped by subject. +

+
+ {rows.length} events +
+
+ {rows.map((event) => { + const active = selected?.id === event.id; + return ( + + ); + })} +
+
+ + {selected && ( + + )}
)}
diff --git a/src/components/global-shell/global-attention-drawer.tsx b/src/components/global-shell/global-attention-drawer.tsx new file mode 100644 index 00000000..bbe44f9b --- /dev/null +++ b/src/components/global-shell/global-attention-drawer.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Activity, Bell, Check, ChevronRight, Inbox } from "lucide-react"; +import { SidePanel } from "@/components/ui/modal"; +import { EmptyState, Spinner } from "@/components/ui"; +import { trpc } from "@/lib/trpc"; +import { relativeTime } from "@/lib/utils"; +import { WsChip } from "./mission-control-home"; + +/** Cross-workspace counterpart to the workspace ActivityDrawer. */ +export function GlobalAttentionBell() { + const [open, setOpen] = useState(false); + const utils = trpc.useUtils(); + const badge = trpc.inbox.badge.useQuery(undefined, { + refetchInterval: 15_000, + refetchOnWindowFocus: true, + }); + const alertCount = trpc.notification.globalUnreadCount.useQuery(undefined, { + refetchInterval: 15_000, + refetchOnWindowFocus: true, + }); + const work = trpc.global.work.useQuery(undefined, { + enabled: open, + refetchOnWindowFocus: true, + }); + const activity = trpc.global.activity.useQuery(undefined, { + enabled: open, + refetchOnWindowFocus: true, + }); + const alerts = trpc.notification.globalList.useQuery(undefined, { + enabled: open, + refetchOnWindowFocus: true, + }); + const markAlertsRead = trpc.notification.globalMarkRead.useMutation({ + onSuccess: async () => { + await Promise.all([ + utils.inbox.badge.invalidate(), + utils.notification.globalUnreadCount.invalidate(), + utils.notification.globalList.invalidate(), + ]); + }, + }); + const unread = (badge.data?.count ?? 0) + (alertCount.data?.count ?? 0); + const tip = unread > 0 ? `${unread} unread attention items` : "No unread attention items"; + + return ( + <> + + + +
+
+
+

Needs your attention

+

One shared attention queue.

+
+ +
+ +
+ {work.isLoading || activity.isLoading || alerts.isLoading ? ( +
+ +
+ ) : ( +
+ {(alerts.data?.length ?? 0) > 0 && ( +
+
+ +

Operational alerts

+ + {alertCount.data?.count ?? 0} unread + +
+
+ {alerts.data!.slice(0, 5).map((alert) => ( + setOpen(false)} + className="focus-ring flex min-h-14 items-start gap-2.5 px-3 py-2.5 hover:bg-subtle" + > + + + + {alert.notification.summary} + + + {alert.notification.recommendedAction} + + + + {relativeTime(alert.createdAt)} + + + ))} +
+
+ )} + +
+
+ +

Assigned to you

+ + {work.data?.length ?? 0} open + +
+ {(work.data?.length ?? 0) === 0 ? ( + + ) : ( +
+ {work.data!.slice(0, 5).map((issue) => ( + setOpen(false)} + className="focus-ring flex min-h-12 items-center gap-2.5 px-3 py-2 hover:bg-subtle" + > + + + {issue.workspace.key}-{issue.number} + + {issue.title} + + + ))} +
+ )} + setOpen(false)} + className="focus-ring flex min-h-10 items-center justify-center border-t border-border/70 text-xs font-medium text-muted-foreground hover:bg-subtle hover:text-foreground" + > + Open global inbox + +
+ +
+
+ +

Recent activity

+
+
+ {(activity.data ?? []).slice(0, 5).map((event) => ( + setOpen(false)} + className="focus-ring flex min-h-12 items-start gap-2.5 px-3 py-2 hover:bg-subtle" + > + + + {event.title} + {event.detail && ( + + {event.detail} + + )} + + + {relativeTime(event.createdAt)} + + + ))} +
+ setOpen(false)} + className="focus-ring flex min-h-10 items-center justify-center border-t border-border/70 text-xs font-medium text-muted-foreground hover:bg-subtle hover:text-foreground" + > + View all activity + +
+
+ )} +
+
+
+ + ); +} diff --git a/src/components/global-shell/global-inbox.tsx b/src/components/global-shell/global-inbox.tsx index a64bb5d2..fa14058c 100644 --- a/src/components/global-shell/global-inbox.tsx +++ b/src/components/global-shell/global-inbox.tsx @@ -1,24 +1,36 @@ "use client"; +import { useEffect, useRef, useState } from "react"; import Link from "next/link"; -import { CircleDot } from "lucide-react"; +import { ArrowUpRight, Bot, CircleDot, Clock, MessageCircle, Tag } from "lucide-react"; import { Spinner, EmptyState } from "@/components/ui"; import { trpc } from "@/lib/trpc"; -import { relativeTime } from "@/lib/utils"; +import { cn, relativeTime } from "@/lib/utils"; import { WsChip } from "./mission-control-home"; /** - * Global inbox — issues assigned to the signed-in user across every - * workspace they belong to, read-only, each row stamped with a workspace - * chip. The cross-workspace counterpart to a workspace's `/inbox` - * (per the design's global nav). Writes happen inside a workspace. + * Global assigned-work inbox. Rows select a read-only inspector so operators + * can understand the work before crossing into the owning workspace. */ export function GlobalInbox() { const work = trpc.global.work.useQuery(); const rows = work.data ?? []; + const [selectedId, setSelectedId] = useState(null); + const selected = rows.find((row) => row.id === selectedId) ?? rows[0] ?? null; + const utils = trpc.useUtils(); + const visited = useRef(false); + const visit = trpc.inbox.visit.useMutation({ + onSuccess: () => void utils.inbox.badge.invalidate(), + }); + + useEffect(() => { + if (visited.current) return; + visited.current = true; + visit.mutate(); + }, [visit]); return ( -
+
{work.isLoading ? (
@@ -27,36 +39,160 @@ export function GlobalInbox() { } title="Your inbox is clear" - description="Issues assigned to you across all your workspaces show up here. Enter a workspace to pick up work." + description="Active issues assigned to you across all workspaces show up here." /> ) : ( -
- {rows.map((issue, i) => ( - 0 ? "border-t border-border/60" : "" - }`} - > - - - {issue.workspace.key}-{issue.number} - - {issue.title} - {issue.status && ( - - {issue.status.name} - +
+
+
+
+

Active assignments

+

+ Select a row to inspect context before opening it. +

+
+ {rows.length} open +
+
+ {rows.map((issue) => { + const active = selected?.id === issue.id; + return ( + + ); + })} +
+
+ + {selected && ( + + )}
)}
diff --git a/src/components/global-shell/global-shell.tsx b/src/components/global-shell/global-shell.tsx index 1d48b611..f66565ed 100644 --- a/src/components/global-shell/global-shell.tsx +++ b/src/components/global-shell/global-shell.tsx @@ -14,7 +14,6 @@ import { ChevronDown, ChevronRight, Eye, - Bell, HelpCircle, Menu, type LucideIcon, @@ -25,6 +24,7 @@ import { Drawer } from "@/components/ui/modal"; import { cn } from "@/lib/utils"; import { workspaceColor } from "@/lib/workspace-color"; import { VersionChip } from "./version-chip"; +import { GlobalAttentionBell } from "./global-attention-drawer"; /** * Global "concourse" shell — the second of Forge's three shells (workspace @@ -114,6 +114,11 @@ export interface GlobalShellUser { instanceRole: "INSTANCE_ADMIN" | "MEMBER"; } +export interface GlobalBreadcrumb { + label: string; + href?: string; +} + const GLOBAL_NAV: { href: string; icon: LucideIcon; label: string; hint: string }[] = [ { href: "/", icon: Sparkles, label: "Mission Control", hint: "Across all workspaces" }, { href: "/agents", icon: Bot, label: "Agents", hint: "Fleet & profiles" }, @@ -125,7 +130,8 @@ function ActivityPill() { return ( Activity - {/* keyboard hint is desktop-only */} - - 5 - ); } @@ -381,7 +383,7 @@ function GlobalTopBar({ scope = "read", onOpenNav, }: { - crumbs: string[]; + crumbs: GlobalBreadcrumb[]; scope?: "read" | "control"; onOpenNav?: () => void; }) { @@ -398,15 +400,32 @@ function GlobalTopBar({ > -
+ ); } diff --git a/src/server/routers/__tests__/event.test.ts b/src/server/routers/__tests__/event.test.ts index 6a03eb7b..3b58b67e 100644 --- a/src/server/routers/__tests__/event.test.ts +++ b/src/server/routers/__tests__/event.test.ts @@ -200,6 +200,48 @@ describe("eventRouter", () => { expect(matching[0]?.detail).toContain("open the issue to choose the next step"); }); + it("turns repeated action-request status events into one useful decision row", async () => { + const { fixture, prisma, caller } = await setup(); + const issue = await createIssue(fixture, { title: "Unify project lifecycle" }); + const request = await prisma.actionRequest.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + title: `${fixture.workspace.key}-${issue.number} appears ready to close`, + body: "The linked implementation is complete. Confirm whether this can be closed.", + severity: NotificationSeverity.WARNING, + requestedByUserId: fixture.user.id, + }, + }); + await Promise.all( + Array.from({ length: 4 }, (_, index) => + prisma.activityEvent.create({ + data: { + workspaceId: fixture.workspace.id, + kind: EventKind.ISSUE_UPDATED, + actorId: fixture.user.id, + subjectType: "action-request", + subjectId: request.id, + payload: { sequence: index }, + }, + }), + ), + ); + + const result = await caller.timeline({ filter: "decisions", limit: 10 }); + const rows = result.items.filter((item) => item.subject.id === request.id); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + occurrences: 4, + category: "decision", + tone: "warning", + href: `/w/${fixture.workspace.slug}/i/${fixture.workspace.key}-${issue.number}`, + }); + expect(rows[0]?.title).toContain("updated request"); + expect(rows[0]?.detail).toContain("Unify project lifecycle"); + }); + it("rolls up agent attention by questions and blocked runs", async () => { const { fixture, prisma, agent, caller } = await setup(); const issue = await createIssue(fixture, { title: "Review runtime access" }); diff --git a/src/server/routers/__tests__/global.test.ts b/src/server/routers/__tests__/global.test.ts new file mode 100644 index 00000000..3ad23db5 --- /dev/null +++ b/src/server/routers/__tests__/global.test.ts @@ -0,0 +1,68 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { AgentRunStatus } from "@prisma/client"; +import { globalRouter } from "@/server/routers/global"; +import { + buildContext, + createIssue, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "./helpers"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => { + await disconnectPrisma(); +}); + +describe("globalRouter", () => { + it("reports active runs against configured concurrent agent slots", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "GLB" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const issue = await createIssue(fixture, { title: "Capacity check" }); + const [finite, unlimited] = await Promise.all([ + prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Finite agent", + profileKey: "finite-agent", + status: "BUSY", + maxConcurrent: 2, + }, + }), + prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Unlimited agent", + profileKey: "unlimited-agent", + status: "ONLINE", + maxConcurrent: 0, + }, + }), + ]); + await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: finite.id, + status: AgentRunStatus.ACTIVE, + }, + }); + + const caller = globalRouter.createCaller(await buildContext(fixture)); + await expect(caller.summary()).resolves.toMatchObject({ + activeRuns: 1, + agentsOnline: 2, + finiteRunSlots: 2, + unlimitedRunAgents: 1, + }); + + expect(unlimited.maxConcurrent).toBe(0); + }); +}); diff --git a/src/server/routers/__tests__/notification.test.ts b/src/server/routers/__tests__/notification.test.ts index 4eda1bae..6b67c78c 100644 --- a/src/server/routers/__tests__/notification.test.ts +++ b/src/server/routers/__tests__/notification.test.ts @@ -94,6 +94,32 @@ describe("notificationRouter", () => { expect(state.createdAt.toISOString()).toBe(event.createdAt.toISOString()); }); + it("shares unread alerts through the cross-workspace notification lifecycle", async () => { + const setupData = await setup(); + const event = await createStalledEvent(setupData, new Date("2026-04-26T12:30:00Z")); + + await expect(setupData.caller.globalUnreadCount()).resolves.toEqual({ count: 1 }); + const alerts = await setupData.caller.globalList(); + expect(alerts).toHaveLength(1); + expect(alerts[0]).toMatchObject({ + status: NotificationStatus.UNREAD, + workspace: { + id: setupData.fixture.workspace.id, + slug: setupData.fixture.workspace.slug, + }, + event: { id: event.id }, + }); + + await expect(setupData.caller.globalMarkRead()).resolves.toMatchObject({ count: 1 }); + await expect(setupData.caller.globalUnreadCount()).resolves.toEqual({ count: 0 }); + await expect( + setupData.prisma.user.findUniqueOrThrow({ + where: { id: setupData.fixture.user.id }, + select: { lastInboxVisitAt: true }, + }), + ).resolves.toMatchObject({ lastInboxVisitAt: expect.any(Date) }); + }); + it("persists read, acknowledge, dismiss, and resolve lifecycle state", async () => { const setupData = await setup(); await createStalledEvent(setupData, new Date("2026-04-26T13:00:00Z")); diff --git a/src/server/routers/event.ts b/src/server/routers/event.ts index 0b23cbaa..f9ba36bb 100644 --- a/src/server/routers/event.ts +++ b/src/server/routers/event.ts @@ -303,7 +303,7 @@ export const eventRouter = router({ .query(async ({ ctx, input }) => { const workspace = await ctx.db.workspace.findUniqueOrThrow({ where: { id: ctx.workspaceId }, - select: { slug: true, key: true }, + select: { id: true, slug: true, name: true, key: true }, }); const ownedChatWhere = await ownedChatActivityWhere(ctx.db, { workspaceId: ctx.workspaceId, @@ -634,14 +634,14 @@ export const eventRouter = router({ }), }); -type TimelineReferenceHydration = Awaited>; -type ActivityRow = Prisma.ActivityEventGetPayload<{ +export type TimelineReferenceHydration = Awaited>; +export type ActivityRow = Prisma.ActivityEventGetPayload<{ include: { actor: { select: { id: true; name: true; handle: true; image: true } }; actorAgent: { select: { id: true; name: true; profileKey: true; avatar: true } }; }; }>; -type TimelineWorkspace = { slug: string; key: string }; +export type TimelineWorkspace = { id: string; slug: string; name: string; key: string }; type TimelineTone = "neutral" | "success" | "warning" | "danger" | "muted"; type AgentAttentionItem = { @@ -713,7 +713,7 @@ async function mineActivityWhere( ]; } -async function hydrateTimelineReferences( +export async function hydrateTimelineReferences( db: PrismaClient | Prisma.TransactionClient, input: { workspaceId: string; rows: ActivityRow[] }, ) { @@ -723,6 +723,7 @@ async function hydrateTimelineReferences( const goalIds = new Set(); const planIds = new Set(); const stepIds = new Set(); + const actionRequestIds = new Set(); for (const row of input.rows) { const payload = payloadRecord(row.payload); @@ -732,6 +733,7 @@ async function hydrateTimelineReferences( if (row.subjectType === "goal") goalIds.add(row.subjectId); if (row.subjectType === "execution-plan") planIds.add(row.subjectId); if (row.subjectType === "execution-step") stepIds.add(row.subjectId); + if (row.subjectType === "action-request") actionRequestIds.add(row.subjectId); addPayloadString(payload, "issueId", issueIds); addPayloadString(payload, "agentId", agentIds); addPayloadString(payload, "assignedAgentId", agentIds); @@ -742,7 +744,7 @@ async function hydrateTimelineReferences( addPayloadString(payload, "stepId", stepIds); } - const [issues, agents, runs, goals, plans, steps] = await Promise.all([ + const [issues, agents, runs, goals, plans, steps, actionRequests] = await Promise.all([ issueIds.size ? db.issue.findMany({ where: { workspaceId: input.workspaceId, id: { in: Array.from(issueIds) } }, @@ -814,6 +816,29 @@ async function hydrateTimelineReferences( }, }) : Promise.resolve([]), + actionRequestIds.size + ? db.actionRequest.findMany({ + where: { + workspaceId: input.workspaceId, + id: { in: Array.from(actionRequestIds) }, + }, + select: { + id: true, + title: true, + body: true, + status: true, + severity: true, + issue: { + select: { + id: true, + number: true, + title: true, + workspace: { select: { key: true } }, + }, + }, + }, + }) + : Promise.resolve([]), ]); return { @@ -823,10 +848,11 @@ async function hydrateTimelineReferences( goals: new Map(goals.map((row) => [row.id, row])), plans: new Map(plans.map((row) => [row.id, row])), steps: new Map(steps.map((row) => [row.id, row])), + actionRequests: new Map(actionRequests.map((row) => [row.id, row])), }; } -function mapTimelineRow( +export function mapTimelineRow( row: ActivityRow, refs: TimelineReferenceHydration, workspace: TimelineWorkspace, @@ -868,10 +894,12 @@ function mapTimelineRow( : payloadString(payload, "stepId") ? (refs.steps.get(payloadString(payload, "stepId")!) ?? null) : null; + const actionRequest = + row.subjectType === "action-request" ? (refs.actionRequests.get(row.subjectId) ?? null) : null; const actor = actorLabel(row); const actorKind = row.actorAgent ? "agent" : row.actor ? "user" : "system"; - const targetIssue = issue ?? run?.issue ?? step?.issue ?? null; + const targetIssue = issue ?? run?.issue ?? step?.issue ?? actionRequest?.issue ?? null; const targetAgent = agent ?? run?.agent ?? row.actorAgent ?? null; const targetLabel = targetIssue ? issueLabel(targetIssue) : null; const agentLabel = targetAgent?.profileKey ? `@${targetAgent.profileKey}` : "Agent"; @@ -903,6 +931,10 @@ function mapTimelineRow( href = `/w/${workspace.slug}/agents/${targetAgent.profileKey}`; category = "agent"; } + if (actionRequest && !targetIssue) { + href = `/w/${workspace.slug}/command-center`; + category = "decision"; + } switch (row.kind) { case EventKind.ISSUE_CREATED: @@ -910,8 +942,17 @@ function mapTimelineRow( detail = targetIssue?.title ?? detail; break; case EventKind.ISSUE_UPDATED: - title = `${actor} updated ${targetLabel ?? "an issue"}`; - detail = targetIssue?.title ?? detail; + if (actionRequest) { + title = `${actor} updated request: ${actionRequest.title}`; + detail = targetIssue + ? `${targetLabel} · ${targetIssue.title}` + : (actionRequest.body ?? detail); + tone = actionRequest.status === "OPEN" ? "warning" : "muted"; + category = "decision"; + } else { + title = `${actor} updated ${targetLabel ?? "an issue"}`; + detail = targetIssue?.title ?? detail; + } break; case EventKind.ISSUE_STATUS_CHANGED: title = `${actor} moved ${targetLabel ?? "an issue"}`; @@ -1097,21 +1138,23 @@ function mapTimelineRow( goal?.title ?? plan?.title ?? step?.title ?? + actionRequest?.title ?? targetAgent?.profileKey ?? row.subjectType, }, occurrences: 1, + workspace, }; } -type MappedTimelineRow = NonNullable>; +export type MappedTimelineRow = NonNullable>; -function collapseRecurringTimelineRows(items: MappedTimelineRow[]): MappedTimelineRow[] { +export function collapseRecurringTimelineRows(items: MappedTimelineRow[]): MappedTimelineRow[] { const out: MappedTimelineRow[] = []; const indexByKey = new Map(); for (const item of items) { - if (!COLLAPSIBLE_TIMELINE_KINDS.has(item.kind)) { + if (!COLLAPSIBLE_TIMELINE_KINDS.has(item.kind) && item.subject.type !== "action-request") { out.push(item); continue; } diff --git a/src/server/routers/global.ts b/src/server/routers/global.ts index 84564743..b74e92ac 100644 --- a/src/server/routers/global.ts +++ b/src/server/routers/global.ts @@ -4,6 +4,11 @@ import { deriveRuntimeHealthStatus } from "@/server/services/runtime-status"; import { runtimeConfigStatus } from "@/server/services/runtime-config"; import { summarizeRuntimeSelfTest } from "@/server/services/runtime-self-test"; import { summarizeRuntimeInfo } from "@/server/services/runtime-info"; +import { + collapseRecurringTimelineRows, + hydrateTimelineReferences, + mapTimelineRow, +} from "@/server/routers/event"; /** * Cross-workspace, read-only aggregations for the global "concourse" @@ -45,7 +50,11 @@ export const globalRouter = router({ const [members, openIssues, agents, activeRuns, lastEvent] = await Promise.all([ ctx.db.membership.count({ where: { workspaceId: ws.id } }), ctx.db.issue.count({ - where: { workspaceId: ws.id, deletedAt: null, status: { category: { notIn: ["DONE", "CANCELED"] } } }, + where: { + workspaceId: ws.id, + deletedAt: null, + status: { category: { notIn: ["DONE", "CANCELED"] } }, + }, }), ctx.db.agent.count({ where: { workspaceId: ws.id, archivedAt: null } }), ctx.db.agentRun.count({ where: { workspaceId: ws.id, status: "ACTIVE" } }), @@ -75,12 +84,23 @@ export const globalRouter = router({ /** Metric tiles for the Mission Control header. */ summary: globalProcedure.query(async ({ ctx }) => { const wsIds = await memberWorkspaceIds(ctx.db, ctx.session.user.id); - const [openIssues, activeRuns, agentsOnline, runtimes] = await Promise.all([ + const [openIssues, activeRuns, onlineAgents, runtimes] = await Promise.all([ ctx.db.issue.count({ - where: { workspaceId: { in: wsIds }, deletedAt: null, status: { category: { notIn: ["DONE", "CANCELED"] } } }, + where: { + workspaceId: { in: wsIds }, + deletedAt: null, + status: { category: { notIn: ["DONE", "CANCELED"] } }, + }, }), ctx.db.agentRun.count({ where: { workspaceId: { in: wsIds }, status: "ACTIVE" } }), - ctx.db.agent.count({ where: { workspaceId: { in: wsIds }, archivedAt: null, status: { in: ["ONLINE", "BUSY"] } } }), + ctx.db.agent.findMany({ + where: { + workspaceId: { in: wsIds }, + archivedAt: null, + status: { in: ["ONLINE", "BUSY"] }, + }, + select: { id: true, maxConcurrent: true }, + }), ctx.db.runtime.findMany({ where: { ownerId: ctx.session.user.id, archivedAt: null }, select: { @@ -100,12 +120,21 @@ export const globalRouter = router({ }, }), ]); - const runtimesOnline = runtimes.filter((r) => deriveRuntimeHealthStatus(r).kind === "online").length; + const runtimesOnline = runtimes.filter( + (r) => deriveRuntimeHealthStatus(r).kind === "online", + ).length; + const finiteRunSlots = onlineAgents.reduce( + (total, agent) => total + (agent.maxConcurrent > 0 ? agent.maxConcurrent : 0), + 0, + ); + const unlimitedRunAgents = onlineAgents.filter((agent) => agent.maxConcurrent === 0).length; return { workspaceCount: wsIds.length, openIssues, activeRuns, - agentsOnline, + agentsOnline: onlineAgents.length, + finiteRunSlots, + unlimitedRunAgents, runtimeCount: runtimes.length, runtimesOnline, }; @@ -269,7 +298,14 @@ export const globalRouter = router({ work: globalProcedure.query(async ({ ctx }) => { const wsIds = await memberWorkspaceIds(ctx.db, ctx.session.user.id); const assignments = await ctx.db.issueAssignee.findMany({ - where: { userId: ctx.session.user.id, issue: { workspaceId: { in: wsIds }, deletedAt: null } }, + where: { + userId: ctx.session.user.id, + issue: { + workspaceId: { in: wsIds }, + deletedAt: null, + status: { category: { notIn: ["DONE", "CANCELED"] } }, + }, + }, take: 30, orderBy: { issue: { updatedAt: "desc" } }, select: { @@ -278,14 +314,52 @@ export const globalRouter = router({ id: true, number: true, title: true, + description: true, + priority: true, + dueDate: true, updatedAt: true, status: { select: { name: true, category: true, color: true } }, + project: { select: { id: true, name: true, key: true, color: true } }, + labels: { + select: { label: { select: { id: true, name: true, color: true } } }, + }, + assignedAgent: { select: { id: true, name: true, profileKey: true } }, + agentRuns: { + where: { status: { in: ["ACTIVE", "WAITING"] } }, + orderBy: [{ lastEventAt: "desc" }, { startedAt: "desc" }], + take: 1, + select: { + id: true, + status: true, + currentStep: true, + agent: { select: { id: true, name: true, profileKey: true } }, + }, + }, + comments: { + where: { deletedAt: null, kind: "BODY" }, + orderBy: { createdAt: "desc" }, + take: 1, + select: { + id: true, + body: true, + createdAt: true, + author: { select: { id: true, name: true, handle: true } }, + authoringAgent: { select: { id: true, name: true, profileKey: true } }, + }, + }, workspace: { select: { id: true, slug: true, name: true, key: true } }, }, }, }, }); - return assignments.map((a) => a.issue); + return assignments.map((assignment) => ({ + ...assignment.issue, + labels: assignment.issue.labels.map((row) => row.label), + currentRun: assignment.issue.agentRuns[0] ?? null, + latestComment: assignment.issue.comments[0] ?? null, + agentRuns: undefined, + comments: undefined, + })); }), /** Recent activity across all the caller's workspaces, read-only. */ @@ -293,20 +367,31 @@ export const globalRouter = router({ const wsIds = await memberWorkspaceIds(ctx.db, ctx.session.user.id); const events = await ctx.db.activityEvent.findMany({ where: { workspaceId: { in: wsIds } }, - take: 40, + take: 100, orderBy: { createdAt: "desc" }, - select: { - id: true, - kind: true, - subjectType: true, - subjectId: true, - payload: true, - createdAt: true, + include: { actor: { select: { id: true, name: true, handle: true, image: true } }, actorAgent: { select: { id: true, profileKey: true, name: true, avatar: true } }, workspace: { select: { id: true, slug: true, name: true, key: true } }, }, }); - return events; + const grouped = new Map(); + for (const event of events) { + const rows = grouped.get(event.workspaceId) ?? []; + rows.push(event); + grouped.set(event.workspaceId, rows); + } + const refs = new Map( + await Promise.all( + Array.from(grouped.entries()).map( + async ([workspaceId, rows]) => + [workspaceId, await hydrateTimelineReferences(ctx.db, { workspaceId, rows })] as const, + ), + ), + ); + const mapped = events.map((event) => + mapTimelineRow(event, refs.get(event.workspaceId)!, event.workspace), + ); + return collapseRecurringTimelineRows(mapped).slice(0, 40); }), }); diff --git a/src/server/routers/notification.ts b/src/server/routers/notification.ts index 3bb62548..8669305c 100644 --- a/src/server/routers/notification.ts +++ b/src/server/routers/notification.ts @@ -59,6 +59,37 @@ const unsubscribePushInput = z.object({ endpoint: z.string().url().max(4096), }); +async function memberWorkspaces(db: PrismaClient, userId: string) { + const memberships = await db.membership.findMany({ + where: { userId, workspace: { deletedAt: null } }, + select: { + workspace: { select: { id: true, slug: true, name: true, key: true } }, + }, + }); + return memberships.map((membership) => membership.workspace); +} + +async function materializeGlobalNotifications( + db: PrismaClient, + userId: string, + workspaces: Awaited>, +) { + await Promise.all( + workspaces.map(async (workspace) => { + await materializeRecentNotifications(db, { + workspaceId: workspace.id, + userId, + limit: 100, + }); + await reconcileRecoveredNotifications(db, { + workspaceId: workspace.id, + userId, + limit: 100, + }); + }), + ); +} + export const notificationRouter = router({ pushConfig: protectedProcedure.query(() => ({ enabled: isWebPushConfigured(), @@ -164,6 +195,87 @@ export const notificationRouter = router({ return { count }; }), + /** Shared unread alert count for Mission Control's cross-workspace bell. */ + globalUnreadCount: protectedProcedure.query(async ({ ctx }) => { + const userId = ctx.session.user.id; + const workspaces = await memberWorkspaces(ctx.db, userId); + if (workspaces.length === 0) return { count: 0 }; + await materializeGlobalNotifications(ctx.db, userId, workspaces); + const count = await ctx.db.notificationState.count({ + where: { + workspaceId: { in: workspaces.map((workspace) => workspace.id) }, + userId, + status: NotificationStatus.UNREAD, + }, + }); + return { count }; + }), + + /** Recent actionable alerts across every workspace the caller belongs to. */ + globalList: protectedProcedure.query(async ({ ctx }) => { + const userId = ctx.session.user.id; + const workspaces = await memberWorkspaces(ctx.db, userId); + if (workspaces.length === 0) return []; + await materializeGlobalNotifications(ctx.db, userId, workspaces); + + const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace])); + const rows = await ctx.db.notificationState.findMany({ + where: { + workspaceId: { in: workspaces.map((workspace) => workspace.id) }, + userId, + status: { in: [...ACTIVE_NOTIFICATION_STATUSES] }, + }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: 30, + include: notificationStateInclude(), + }); + const rowsByWorkspace = new Map(); + for (const row of rows) { + const workspaceRows = rowsByWorkspace.get(row.workspaceId) ?? []; + workspaceRows.push(row); + rowsByWorkspace.set(row.workspaceId, workspaceRows); + } + const items = ( + await Promise.all( + Array.from(rowsByWorkspace.entries()).map(async ([workspaceId, workspaceRows]) => { + const workspace = workspaceById.get(workspaceId)!; + const hydrated = await buildNotificationListItems(ctx.db, workspaceId, workspaceRows); + return hydrated.map((notification) => ({ ...notification, workspace })); + }), + ) + ).flat(); + return items + .sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()) + .slice(0, 20); + }), + + /** Atomically mark the shared alert and inbox attention queues as seen. */ + globalMarkRead: protectedProcedure.mutation(async ({ ctx }) => { + const userId = ctx.session.user.id; + const workspaces = await memberWorkspaces(ctx.db, userId); + const visitedAt = new Date(); + if (workspaces.length === 0) { + await ctx.db.user.update({ where: { id: userId }, data: { lastInboxVisitAt: visitedAt } }); + return { count: 0, visitedAt }; + } + await materializeGlobalNotifications(ctx.db, userId, workspaces); + const [result] = await ctx.db.$transaction([ + ctx.db.notificationState.updateMany({ + where: { + workspaceId: { in: workspaces.map((workspace) => workspace.id) }, + userId, + status: NotificationStatus.UNREAD, + }, + data: { status: NotificationStatus.READ, readAt: visitedAt }, + }), + ctx.db.user.update({ + where: { id: userId }, + data: { lastInboxVisitAt: visitedAt }, + }), + ]); + return { count: result.count, visitedAt }; + }), + upsert: workspaceProcedure .input(z.object({ eventId: z.string().cuid() })) .mutation(async ({ ctx, input }) => { diff --git a/tests/e2e/mission-control-agent-management.spec.ts b/tests/e2e/mission-control-agent-management.spec.ts index f66ea0c5..d5f98b85 100644 --- a/tests/e2e/mission-control-agent-management.spec.ts +++ b/tests/e2e/mission-control-agent-management.spec.ts @@ -20,9 +20,31 @@ test.describe.serial("Mission Control agent management", () => { await createDialog.getByRole("button", { name: "Create profile" }).click(); await expect(page.getByText(name, { exact: true })).toBeVisible(); + await page.setViewportSize({ width: 900, height: 650 }); + const fleetScrollRegion = page.getByTestId("agent-fleet-scroll-region"); + await expect + .poll(() => + fleetScrollRegion.evaluate((element) => element.scrollHeight > element.clientHeight), + ) + .toBe(true); + await fleetScrollRegion.evaluate((element) => element.scrollTo(0, element.scrollHeight)); + await expect + .poll(() => fleetScrollRegion.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + await page.getByText(name, { exact: true }).click(); await expect(page).toHaveURL(/\/agents\/[^/]+$/); await expect(page.getByText("Global profile", { exact: true }).first()).toBeVisible(); + const profileScrollRegion = page.getByTestId("agent-profile-scroll-region"); + await expect + .poll(() => + profileScrollRegion.evaluate((element) => element.scrollHeight > element.clientHeight), + ) + .toBe(true); + await profileScrollRegion.evaluate((element) => element.scrollTo(0, element.scrollHeight)); + await expect + .poll(() => profileScrollRegion.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); const detailUrl = new URL(page.url()); const profileId = detailUrl.pathname.split("/").at(-1)!; diff --git a/tests/e2e/mobile-smoke.spec.ts b/tests/e2e/mobile-smoke.spec.ts index 73e0ca69..d788c5a1 100644 --- a/tests/e2e/mobile-smoke.spec.ts +++ b/tests/e2e/mobile-smoke.spec.ts @@ -122,7 +122,7 @@ test.describe("Mobile smoke", () => { await page.goto("/"); await expect(page.getByRole("heading", { name: "Mission Control" })).toBeVisible(); - const activityPill = page.getByTitle("Activity · live runs + chat (G 5)"); + const activityPill = page.getByTitle("Activity · live runs and changes · shortcut G then 5"); await expect(activityPill).toBeVisible(); await expectNoDocumentHorizontalOverflow(page, `mission control activity pill at ${width}px`); diff --git a/tests/e2e/multiws-restructure.spec.ts b/tests/e2e/multiws-restructure.spec.ts index db7006da..6d359c43 100644 --- a/tests/e2e/multiws-restructure.spec.ts +++ b/tests/e2e/multiws-restructure.spec.ts @@ -22,6 +22,72 @@ test.describe("multi-workspace restructure", () => { await expect(page.getByRole("heading", { name: "Mission Control" })).toBeVisible(); // Read-only badge in the global top bar. await expect(page.getByText("Read-only across workspaces", { exact: true })).toBeVisible(); + await expect(page.getByText("Run capacity", { exact: true })).toBeVisible(); + const activityPill = page.getByRole("link", { name: /Open activity/ }); + await expect(activityPill).toBeVisible(); + await expect(activityPill).not.toContainText("5"); + }); + + test("global notifications and help controls open useful surfaces", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await page.getByRole("button", { name: /Open notifications/ }).click(); + const notifications = page.getByRole("dialog", { name: "Notifications" }); + await expect(notifications).toBeVisible(); + await expect(notifications.getByText("One shared attention queue.")).toBeVisible(); + await expect(notifications.getByRole("link", { name: "Open global inbox" })).toBeVisible(); + await notifications.getByRole("button", { name: "Close" }).click(); + + await page.getByRole("button", { name: "Open keyboard shortcuts" }).click(); + await expect( + page.getByRole("dialog").getByText("Keyboard shortcuts", { exact: true }), + ).toBeVisible(); + }); + + test("global breadcrumbs navigate up the hierarchy", async ({ page }) => { + await page.goto("/activity", { waitUntil: "domcontentloaded" }); + + const breadcrumb = page.getByRole("navigation", { name: "Breadcrumb" }); + await expect(breadcrumb.getByRole("link", { name: "Forge" })).toHaveAttribute("href", "/"); + await expect(breadcrumb.getByText("Activity", { exact: true })).toHaveAttribute( + "aria-current", + "page", + ); + await expect(breadcrumb.getByRole("link", { name: "Activity" })).toHaveCount(0); + + await breadcrumb.getByRole("link", { name: "Forge" }).click(); + await expect(page).toHaveURL(/\/$/); + await expect(page.getByRole("heading", { name: "Mission Control" })).toBeVisible(); + }); + + test("global inbox and activity provide inspectable context", async ({ page }) => { + await page.goto("/inbox", { waitUntil: "domcontentloaded" }); + const openIssue = page.getByRole("link", { name: "Open issue" }); + const clearInbox = page.getByText("Your inbox is clear", { exact: true }); + await expect(openIssue.or(clearInbox)).toBeVisible(); + if (await openIssue.isVisible()) { + await expect(page.getByRole("heading", { name: "Active assignments" })).toBeVisible(); + await expect( + page.getByText("Select a row to inspect context before opening it."), + ).toBeVisible(); + await expect(openIssue).toBeVisible(); + } else { + await expect(clearInbox).toBeVisible(); + } + + await page.goto("/activity", { waitUntil: "domcontentloaded" }); + const openSource = page.getByRole("link", { name: "Open source" }); + const noActivity = page.getByText("No activity yet", { exact: true }); + await expect(openSource.or(noActivity)).toBeVisible(); + if (await openSource.isVisible()) { + await expect(page.getByRole("heading", { name: "Recent changes" })).toBeVisible(); + await expect( + page.getByText("Repeated watchdog updates are grouped by subject."), + ).toBeVisible(); + await expect(openSource).toBeVisible(); + } else { + await expect(noActivity).toBeVisible(); + } }); test("workspace switcher navigates into a workspace", async ({ page }) => {