+ );
+ });
+
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("plugin-topbar").textContent).toBe("t1|w1|s2|s1,s2");
+ });
+
+ it("includes the active session id even when the store list omits it", () => {
+ pluginRegistry.forPlugin("plugin-a").registerComponent(SLOT, ({ slotProps }) => {
+ const ctx = slotProps as ChatTopBarSlotProps;
+ return
{ctx.sessionIds.join(",")}
;
+ });
+
+ // taskId with no store entry -> only the active session propagates.
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("plugin-topbar").textContent).toBe("s9");
+ });
+
+ it("propagates only the active session when taskId is null", () => {
+ pluginRegistry.forPlugin("plugin-a").registerComponent(SLOT, ({ slotProps }) => {
+ const ctx = slotProps as ChatTopBarSlotProps;
+ return (
+
+ );
+ });
+
+ render();
+
+ expect(screen.getByTestId("plugin-topbar").textContent).toBe("null|s9|s9");
+ });
+});
diff --git a/apps/web/components/task/task-top-bar-plugin-actions.tsx b/apps/web/components/task/task-top-bar-plugin-actions.tsx
new file mode 100644
index 0000000000..b0386866fa
--- /dev/null
+++ b/apps/web/components/task/task-top-bar-plugin-actions.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useCallback, useMemo } from "react";
+import { useOptionalAppStore } from "@/components/state-provider";
+import { PluginSlot } from "@/components/plugins/plugin-slot";
+import type { AppState } from "@/lib/state/store";
+import type { TaskSession } from "@/lib/types/http";
+
+/**
+ * Props forwarded to every plugin component registered for the `chat-top-bar`
+ * slot (`registry.registerComponent("chat-top-bar", Component)`). This is the
+ * session top bar's right-hand cluster, beside the CPU/DB metrics and the
+ * document / editors / debug controls — the place for at-a-glance status a
+ * plugin wants to surface for the current task.
+ *
+ * A task can hold several sessions; the top bar is bound to one at a time
+ * (`activeSessionId`), so both it and the full `sessionIds` list are provided,
+ * mirroring the `chat-input-actions` slot. These are kandev session ids;
+ * resolving one to an agent/ACP transcript id (e.g. to key cost data on a
+ * session) is the plugin's job — do it server-side in the plugin backend
+ * through the Host data API, not here. See PLUGIN-API.md.
+ */
+export type ChatTopBarSlotProps = {
+ /** Task the top bar belongs to, or null before one exists. */
+ taskId: string | null;
+ /** Display title of the task, when known. */
+ taskTitle?: string;
+ /** Workspace the task lives in, when known. */
+ workspaceId: string | null;
+ /** Session the top bar is currently bound to, or null before one exists. */
+ activeSessionId: string | null;
+ /** Every kandev session id on the task (includes `activeSessionId`). */
+ sessionIds: string[];
+};
+
+const EMPTY_SESSIONS: TaskSession[] = [];
+
+/**
+ * Plugin extension point in the session top bar, rendered alongside the
+ * first-party controls (CPU/DB metrics, document/editor menus, debug toggle).
+ * Renders every plugin component registered for the `chat-top-bar` slot (each
+ * isolated behind its own error boundary via `PluginSlot`) and forwards the
+ * current task, workspace, and all of its session ids as `slotProps`.
+ */
+export function TaskTopBarPluginActions(props: {
+ sessionId: string | null;
+ taskId: string | null;
+ taskTitle?: string;
+ workspaceId: string | null;
+}) {
+ const { sessionId, taskId, taskTitle, workspaceId } = props;
+ // itemsByTaskId holds a stable per-task array reference (updated only when
+ // that task's sessions change), so selecting it avoids a new-array-per-render.
+ // Read optionally so the top bar can render in isolation (unit tests) without
+ // a StateProvider.
+ const selectSessions = useCallback(
+ (s: AppState): TaskSession[] =>
+ taskId ? (s.taskSessionsByTask.itemsByTaskId[taskId] ?? EMPTY_SESSIONS) : EMPTY_SESSIONS,
+ [taskId],
+ );
+ const taskSessions = useOptionalAppStore(selectSessions, EMPTY_SESSIONS);
+
+ const slotProps = useMemo(() => {
+ const sessionIds: string[] = taskSessions.map((session) => session.id);
+ // The active session may not yet be in the store list (freshly prepared);
+ // make sure the plugin always receives it.
+ if (sessionId && !sessionIds.includes(sessionId)) sessionIds.unshift(sessionId);
+ return { taskId, taskTitle, workspaceId, activeSessionId: sessionId, sessionIds };
+ }, [taskSessions, sessionId, taskId, taskTitle, workspaceId]);
+
+ return ;
+}
diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx
index 38f857d5bb..a20448a848 100644
--- a/apps/web/components/task/task-top-bar.tsx
+++ b/apps/web/components/task/task-top-bar.tsx
@@ -20,6 +20,7 @@ import { ExecutorSettingsButton } from "@/components/task/executor-settings-butt
import { TaskUnarchiveButton } from "@/components/task/task-unarchive-button";
import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper";
import { TopbarMetrics } from "@/components/system-metrics/topbar-metrics";
+import { TaskTopBarPluginActions } from "@/components/task/task-top-bar-plugin-actions";
import { isDebugUI } from "@/lib/config";
type TaskTopBarProps = {
@@ -358,6 +359,16 @@ function TopBarRight({
return (
+ {!isArchived && (
+
+
+
+ )}
{isArchived && (
diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts
index 3f25556458..c287c225a5 100644
--- a/apps/web/lib/plugins/types.ts
+++ b/apps/web/lib/plugins/types.ts
@@ -71,10 +71,12 @@ export interface PluginRouteOptions {
/**
* Named slot the host renders via ``. Initial slots:
- * "task-sidebar", "settings-nav", "main-nav-footer", and "chat-input-actions"
+ * "task-sidebar", "settings-nav", "main-nav-footer", "chat-input-actions"
* (icon buttons in the chat composer toolbar, beside the model picker / mic /
* send — receives `{ taskId, taskTitle, activeSessionId, sessionIds }` as
- * `slotProps`). Not a closed union — hosts may register additional slot names.
+ * `slotProps`), and "chat-top-bar" (status in the session top bar, beside the
+ * CPU/DB metrics — receives `{ taskId, taskTitle, workspaceId, activeSessionId,
+ * sessionIds }`). Not a closed union — hosts may register additional slot names.
*/
export type PluginSlotName = string;
diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md
index 3182eebb71..c439bf0865 100644
--- a/docs/plans/plugins/PLUGIN-API.md
+++ b/docs/plans/plugins/PLUGIN-API.md
@@ -123,11 +123,15 @@ interface PluginRegistry {
// Named slot injection. Host renders all components registered for a slot via
// . Initial slots: "task-sidebar",
- // "settings-nav", "main-nav-footer", "chat-input-actions". The last renders
- // icon buttons in the chat composer toolbar (beside the model picker, mic,
- // and send) and forwards `{ taskId, taskTitle, activeSessionId, sessionIds }`
- // as `slotProps` — the active session plus every kandev session id on the
- // task. Resolving a session id to an agent/ACP transcript id (e.g. to key
+ // "settings-nav", "main-nav-footer", "chat-input-actions", "chat-top-bar".
+ // "chat-input-actions" renders icon buttons in the chat composer toolbar
+ // (beside the model picker, mic, and send) and forwards
+ // `{ taskId, taskTitle, activeSessionId, sessionIds }` as `slotProps`.
+ // "chat-top-bar" renders status in the session top bar (beside the CPU/DB
+ // metrics and the document/editor/debug controls) and forwards
+ // `{ taskId, taskTitle, workspaceId, activeSessionId, sessionIds }`. Both
+ // carry the active session plus every kandev session id on the task.
+ // Resolving a session id to an agent/ACP transcript id (e.g. to key
// tokscale cost data on a session) is the plugin's job, done server-side in
// the plugin backend via the Host data API; the host only propagates ids.
registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void;
diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md
index 456807f67a..63099311a9 100644
--- a/docs/public/plugins-authoring.md
+++ b/docs/public/plugins-authoring.md
@@ -332,6 +332,7 @@ plugins at once. Available slots:
| `settings-nav` | Settings navigation tree | — |
| `main-nav-footer` | Footer of the main sidebar | — |
| `chat-input-actions` | Chat composer toolbar, beside the model picker, mic, and send button | `{ taskId, taskTitle, activeSessionId, sessionIds }` |
+| `chat-top-bar` | Session top bar, beside the CPU/DB metrics and the document/editor/debug controls | `{ taskId, taskTitle, workspaceId, activeSessionId, sessionIds }` |
### Chat toolbar actions
@@ -396,6 +397,34 @@ Match the first-party toolbar buttons: `Button` from `host.ui` with
mic/attach controls. `kandev-plugin-hello/ui/bundle.js` ships a working
example.
+### Session top bar
+
+Register a `chat-top-bar` component to surface at-a-glance status in the
+session top bar, beside the first-party CPU/DB metrics and the
+document/editor/debug controls. The host passes the current context as
+`slotProps`:
+
+```ts
+type ChatTopBarSlotProps = {
+ taskId: string | null;
+ taskTitle?: string;
+ workspaceId: string | null;
+ activeSessionId: string | null; // session the top bar is bound to
+ sessionIds: string[]; // every kandev session id on the task
+};
+```
+
+Like `chat-input-actions`, both the active session and the full `sessionIds`
+list are provided (see the note above about resolving kandev session ids to
+ACP transcript ids server-side). The top bar is a compact horizontal strip, so
+keep contributions to small badges or `h-7` buttons that match the native
+metric chips.
+
+```js
+// inside initialize(registry, host):
+registry.registerComponent("chat-top-bar", makeTopBarStatus(host));
+```
+
## Three integration patterns
Both example repos are full, working plugins — read them rather than