Background agents → sidebar section (owner): the retired floating dock's successor
The old activity dock (fixed bottom-right, every view) was a cross-session
widget parked inside the session view — the same boundary confusion that
got the plan window moved. The sidebar is the app's cross-session surface,
so the agents section lives at its bottom strip now (user-locked design):
- ALWAYS present: empty means empty — the header ('Background agents',
matching the Sessions label language) sits alone; nothing hides and
nothing decorates (the total-cost span only renders when populated).
- Populates and grows when processes run: same blobs (sub-agents +
extraction runs, one poll, inferred cost, elapsed heartbeat), capped by
max-h-[40dvh] + internal scroll so a pile never crowds the session list.
- Per-blob END button (unchanged abort contract); the detail popover
becomes an INLINE expansion — the section scrolls, a floating popover
would clip at its bounds.
- Sidebar-native skin: border-t separator matching the sidebar's edge,
session-row hover language, no floating-card chrome.
- Sidebar closed = the learner opted out; no ambient signal for now
(user decision — invisibility until a clearer observability story).
- Narrow-mode count badge/drawer machinery retired with the dock.
app/layout.tsx: ActivityDock mount removed, ActivityProvider stays (the
sidebar consumes the context). Live-verified: populated rows with inline
detail + session link, abort makes the row vanish, idle renders the
header alone, closed sidebar hides the section with nothing floating.
Gates: typecheck, lint, 423/423 tests.
This commit is contained in:
+5
-8
@@ -3,7 +3,7 @@ import { JetBrains_Mono, Source_Sans_3 } from "next/font/google";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "./globals.css";
|
||||
import { AppShell, SidebarProvider } from "@/components/sidebar";
|
||||
import { ActivityDock, ActivityProvider } from "@/components/activity-dock";
|
||||
import { ActivityProvider } from "@/components/activity-dock";
|
||||
|
||||
// Chrome = JetBrains Mono, teaching prose = Source Sans 3, titles = CMU
|
||||
// Serif (the bundled lh-cmu @font-face in globals.css — no CDN).
|
||||
@@ -20,13 +20,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<html lang="en" className={`dark ${ui.variable} ${prose.variable}`}>
|
||||
<body className="min-h-dvh antialiased">
|
||||
<SidebarProvider>
|
||||
<ActivityProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
{/* The reserved bottom-right corner of every view — renders
|
||||
nothing while no background process runs. */}
|
||||
<ActivityDock />
|
||||
</ActivityProvider>
|
||||
</SidebarProvider>
|
||||
<ActivityProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
</ActivityProvider>
|
||||
</SidebarProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ActivityIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon, XIcon } from "lucide-react";
|
||||
import { ActivityIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
||||
import { CONTEXT_LIMITS, jobCost } from "@/src/core";
|
||||
import { formatTokens, formatUsd } from "@/components/usage-format";
|
||||
|
||||
/**
|
||||
* THE ACTIVITY DOCK (wave 4, vision observability): the bottom-right corner
|
||||
* of EVERY view, one blob per running BACKGROUND process — session sub-agents
|
||||
* THE BACKGROUND-AGENTS SECTION (owner, sidebar round — the successor of
|
||||
* the retired bottom-right floating dock): the bottom strip of the LEFT
|
||||
* SIDEBAR, one blob per running BACKGROUND process — session sub-agents
|
||||
* (research/demo/visual) and global extraction runs. Teaching turns are
|
||||
* deliberately NOT here: the dock exists to give background processes the
|
||||
* observability the learner can't otherwise see; an in-flight teaching turn
|
||||
* is fully visible where it happens (user decision, 2026-09).
|
||||
* deliberately NOT here: the section exists to give background processes
|
||||
* the observability the learner can't otherwise see; an in-flight teaching
|
||||
* turn is fully visible where it happens (user decision, 2026-09).
|
||||
*
|
||||
* Data: ONE poll of GET /api/activity (jobs across ALL chats, title-joined,
|
||||
* oldest-first + extraction runs), folded client-side with core/prices.ts.
|
||||
@@ -23,13 +24,18 @@ import { formatTokens, formatUsd } from "@/components/usage-format";
|
||||
* Absent data renders "—", never zero (the doctrine).
|
||||
*
|
||||
* Quiet/ambient by design: no entry animations, stable keys, updates land
|
||||
* in place — polling never flickers. Idle (no jobs, no extractions) renders
|
||||
* NOTHING. Narrow viewports collapse to a count badge → click opens the
|
||||
* drawer. Per blob: kind icon, elapsed, cost, context, activity line, and an
|
||||
* explicit END button (POST /api/activity/abort — the wave-4 backend is
|
||||
* live): 404/409 mean the process is already gone (refresh, no error);
|
||||
* real failures degrade to an inline "Abort failed", never a
|
||||
* dead control).
|
||||
* in place — polling never flickers. ALWAYS present (owner, sidebar round):
|
||||
* empty means EMPTY — the header sits alone, nothing hides; it populates and
|
||||
* GROWS when processes run (max-h + internal scroll so a pile never crowds
|
||||
* the session list). The sidebar's closed state means the learner opted
|
||||
* out — no ambient signal while closed (user decision: invisibility until
|
||||
* a clearer observability story wants building). Per blob: kind icon,
|
||||
* elapsed, cost, activity line, an explicit END button (POST
|
||||
* /api/activity/abort — the wave-4 backend is live): 404/409 mean the
|
||||
* process is already gone (refresh, no error); real failures degrade to
|
||||
* an inline "Abort failed", never a dead control. Detail (context, model,
|
||||
* steps, session link) EXPANDS INLINE — the section scrolls, so a
|
||||
* floating popover would clip at its bounds.
|
||||
*/
|
||||
|
||||
// ─── Wire shapes (client-safe mirrors of server/observability.ts) ────────────
|
||||
@@ -181,22 +187,9 @@ export function formatElapsed(ms: number): string {
|
||||
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
|
||||
}
|
||||
|
||||
// ─── The dock ─────────────────────────────────────────────────────────────────
|
||||
// ─── The section (the sidebar's bottom strip) ─────────────────────────────────
|
||||
|
||||
/** Narrow viewports (< sm) collapse the dock to a count badge → drawer. */
|
||||
function useNarrow(): boolean {
|
||||
const [narrow, setNarrow] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 639px)");
|
||||
const apply = () => setNarrow(mq.matches);
|
||||
apply();
|
||||
mq.addEventListener("change", apply);
|
||||
return () => mq.removeEventListener("change", apply);
|
||||
}, []);
|
||||
return narrow;
|
||||
}
|
||||
|
||||
/** A 1s heartbeat while the dock is on screen — elapsed lines tick. */
|
||||
/** A 1s heartbeat while the section is populated — elapsed lines tick. */
|
||||
function useNow(active: boolean): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
@@ -216,14 +209,12 @@ const KIND_ICON: Record<JobKind, typeof SearchIcon> = {
|
||||
/** Abort states per blob id: pending (fired) / failed (backend absent). */
|
||||
type AbortState = "pending" | "failed";
|
||||
|
||||
export function ActivityDock({ feed: feedProp }: { feed?: ActivityFeed }) {
|
||||
export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
|
||||
const ctx = useContext(ActivityContext);
|
||||
const feed = feedProp ?? ctx.feed;
|
||||
const refresh = ctx.refresh;
|
||||
const blobs = useMemo(() => toBlobs(feed), [feed]);
|
||||
const narrow = useNarrow();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [openBlob, setOpenBlob] = useState<string | null>(null); // detail popover
|
||||
const [openBlob, setOpenBlob] = useState<string | null>(null); // detail, inline-expanded
|
||||
const [abortStates, setAbortStates] = useState<Record<string, AbortState>>({});
|
||||
const now = useNow(blobs.length > 0);
|
||||
// Refs for stable abort handlers (no re-created closures per tick).
|
||||
@@ -266,63 +257,38 @@ export function ActivityDock({ feed: feedProp }: { feed?: ActivityFeed }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (blobs.length === 0) return null; // idle: the dock is invisible
|
||||
|
||||
const count = blobs.length;
|
||||
// Narrow: a quiet count badge; the drawer opens on click.
|
||||
if (narrow && !drawerOpen) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="activity-badge"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
title={`${count} background process${count === 1 ? "" : "es"} running`}
|
||||
aria-label={`${count} background processes running — show activity dock`}
|
||||
className="fixed bottom-3 right-3 z-30 flex items-center gap-1.5 rounded-full border bg-card/90 px-3 py-1.5 text-xs text-muted-foreground shadow-lg backdrop-blur"
|
||||
>
|
||||
<ActivityIcon className="size-3.5 text-primary" />
|
||||
<span className="tabular-nums">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Sidebar-native skin (owner, sidebar round): part of the sidebar's
|
||||
// surface, not a floating card — the border-t separator matches the
|
||||
// sidebar's own edge line, the header matches the "Sessions" label, the
|
||||
// rows match the session rows' hover. Empty means empty: the header sits
|
||||
// alone (the cost span is population, not decoration).
|
||||
return (
|
||||
<div
|
||||
data-testid="activity-dock"
|
||||
className={cn(
|
||||
"fixed bottom-3 right-3 z-30 flex w-72 flex-col gap-1 rounded-xl border bg-card/95 p-2 shadow-lg backdrop-blur",
|
||||
narrow && "max-h-[60dvh] overflow-y-auto",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-1 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<ActivityIcon className="size-3.5" />
|
||||
Background activity
|
||||
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
|
||||
{totalCost === null ? "—" : formatUsd(totalCost)}
|
||||
<section data-testid="activity-section" className="border-t border-border px-3 pb-3 pt-2">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xxs uppercase tracking-widest text-muted-foreground/70">
|
||||
<ActivityIcon className="size-3" />
|
||||
Background agents
|
||||
{blobs.length > 0 && (
|
||||
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
|
||||
{totalCost === null ? "—" : formatUsd(totalCost)}
|
||||
</span>
|
||||
{narrow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
aria-label="Hide activity dock"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{blobs.map((blob) => (
|
||||
<ActivityBlobRow
|
||||
key={blob.id}
|
||||
blob={blob}
|
||||
now={now}
|
||||
detailOpen={openBlob === blob.id}
|
||||
onToggleDetail={() => setOpenBlob((id) => (id === blob.id ? null : blob.id))}
|
||||
abortState={abortStates[blob.id]}
|
||||
onAbort={() => void abort(blob)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{blobs.length > 0 && (
|
||||
<div className="flex max-h-[40dvh] flex-col gap-1 overflow-y-auto">
|
||||
{blobs.map((blob) => (
|
||||
<ActivityBlobRow
|
||||
key={blob.id}
|
||||
blob={blob}
|
||||
now={now}
|
||||
detailOpen={openBlob === blob.id}
|
||||
onToggleDetail={() => setOpenBlob((id) => (id === blob.id ? null : blob.id))}
|
||||
abortState={abortStates[blob.id]}
|
||||
onAbort={() => void abort(blob)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,7 +322,7 @@ function ActivityBlobRow({
|
||||
return (
|
||||
<div
|
||||
data-testid={`activity-blob-${blob.process}`}
|
||||
className="relative rounded-lg bg-background/40 px-2 py-1.5 text-xs transition-colors hover:bg-background/70"
|
||||
className="relative rounded-lg px-2 py-1.5 text-xs transition-colors hover:bg-accent"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -398,7 +364,7 @@ function ActivityBlobRow({
|
||||
{detailOpen && (
|
||||
<div
|
||||
data-testid="activity-blob-detail"
|
||||
className="absolute bottom-full right-0 z-40 mb-1.5 w-72 rounded-xl border bg-card p-3 text-xs shadow-xl"
|
||||
className="mt-1 flex flex-col gap-1 rounded-lg bg-accent/40 p-2 text-muted-foreground"
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<Icon className="size-3.5 text-muted-foreground" />
|
||||
|
||||
+10
-2
@@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon } from "lucide-react";
|
||||
import { ActivitySection } from "@/components/activity-dock";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -285,8 +286,15 @@ function SessionsSidebar() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Background agents (owner, sidebar round): the section is
|
||||
ALWAYS here — empty means empty (the header sits alone); it
|
||||
populates and grows when processes run. The session list is
|
||||
flex-1, so this pins to the bottom. Sidebar closed = the
|
||||
learner opted out (no ambient signal, user decision). */}
|
||||
<ActivitySection />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { ActivityDock, formatElapsed, toBlobs, type ActivityFeed } from "../components/activity-dock.tsx";
|
||||
import { ActivitySection, formatElapsed, toBlobs, type ActivityFeed } from "../components/activity-dock.tsx";
|
||||
import { formatTokens, formatUsd } from "../components/usage-format.ts";
|
||||
|
||||
// The dock's pure layer: folds, formatting, and blob order — then the SSR
|
||||
// render (blobs, "—" doctrine, the abort affordance, the narrow badge).
|
||||
// The section's pure layer: folds, formatting, and blob order — then the SSR
|
||||
// render (blobs, "—" doctrine, the abort affordance, the always-present
|
||||
// header — the sidebar's background-agents strip).
|
||||
|
||||
const NOW = Date.parse("2026-09-09T12:00:00Z");
|
||||
|
||||
@@ -118,21 +119,25 @@ describe("activity dock pure layer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity dock render", () => {
|
||||
it("renders NOTHING when idle (no jobs, no extractions)", () => {
|
||||
assert.equal(renderToStaticMarkup(<ActivityDock feed={{ jobs: [], extractions: [] }} />), "");
|
||||
describe("activity section render", () => {
|
||||
it("renders the ALWAYS-PRESENT header when idle — empty means empty, nothing hides", () => {
|
||||
const html = renderToStaticMarkup(<ActivitySection feed={{ jobs: [], extractions: [] }} />);
|
||||
assert.match(html, /data-testid="activity-section"/);
|
||||
assert.match(html, /Background agents/);
|
||||
assert.doesNotMatch(html, /activity-blob-(job|extraction)/, "no rows while idle");
|
||||
assert.doesNotMatch(html, /activity-total-cost/, "the cost span is population, not decoration");
|
||||
});
|
||||
|
||||
it("renders one blob per running process with cost, elapsed, and the abort affordance", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ActivityDock
|
||||
<ActivitySection
|
||||
feed={{
|
||||
jobs: [job({ id: "j1", kind: "demo", activity: "building sandbox…", title: "Chain-rule demo" })],
|
||||
extractions: [{ chatId: "cx", title: "Old lesson", phase: "merging", startedAt: NOW - 60_000 }],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
assert.match(html, /data-testid="activity-dock"/);
|
||||
assert.match(html, /data-testid="activity-section"/);
|
||||
assert.match(html, /data-testid="activity-blob-job"/);
|
||||
assert.match(html, /data-testid="activity-blob-extraction"/);
|
||||
assert.match(html, /Chain-rule demo/);
|
||||
@@ -142,7 +147,7 @@ describe("activity dock render", () => {
|
||||
assert.match(html, /activity-blob-cost[^>]*>—</);
|
||||
assert.match(html, /data-testid="activity-abort"/, "END on the blob face, popover-independent");
|
||||
assert.match(html, /\bEnd\b/, "the explicit END button");
|
||||
assert.match(html, /Background activity/);
|
||||
assert.match(html, /Background agents/);
|
||||
// Total cost: nothing priced → "—" in the header.
|
||||
assert.match(html, /activity-total-cost[^>]*>—</);
|
||||
});
|
||||
@@ -158,7 +163,7 @@ describe("activity dock render", () => {
|
||||
steps: 1,
|
||||
};
|
||||
const html = renderToStaticMarkup(
|
||||
<ActivityDock
|
||||
<ActivitySection
|
||||
feed={{
|
||||
jobs: [
|
||||
job({ id: "a", usage }),
|
||||
|
||||
Reference in New Issue
Block a user