diff --git a/app/layout.tsx b/app/layout.tsx
index 2a19db7..5719f69 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -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 })
-
- {children}
- {/* The reserved bottom-right corner of every view — renders
- nothing while no background process runs. */}
-
-
-
+
+ {children}
+
+
);
diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx
index 3eab877..f965409 100644
--- a/components/activity-dock.tsx
+++ b/components/activity-dock.tsx
@@ -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 = {
/** 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(null); // detail popover
+ const [openBlob, setOpenBlob] = useState(null); // detail, inline-expanded
const [abortStates, setAbortStates] = useState>({});
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 (
-
- );
- }
-
+ // 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 (
-