feat: add chat-top-bar plugin slot (#1827)

* feat: add chat-top-bar plugin slot

Adds a new named plugin UI slot, chat-top-bar, rendered in the session
top bar beside the CPU/DB metrics and document/editor/debug controls.
Mirrors the existing chat-input-actions slot pattern: a wrapper
component (TaskTopBarPluginActions) assembles slotProps from the store
and renders <PluginSlot name="chat-top-bar" />, forwarding
{ taskId, taskTitle, workspaceId, activeSessionId, sessionIds }.

Updates PLUGIN-API.md, the public authoring guide, and the
PluginSlotName JSDoc to document the new slot. No backend changes
needed since slot names are an open string type.

* fix: address PR review — mobile chat-top-bar slot, gate by archived state

- Wire the chat-top-bar plugin slot into the mobile session top bar
  (session-mobile-top-bar.tsx), matching desktop's slotProps shape.
- Gate the plugin cluster behind !isArchived in TopBarRight, matching
  every other interactive control in that group (port-forward, PR/MR
  buttons, layout preset, editors menu).
- Extract RemoteExecutorIndicator, ApproveButton, and MobileTopBarDialogs
  to keep the mobile top bar under lint complexity/length limits.

* fix: gate mobile chat-top-bar plugin slot by archived state

Threads isArchived through TaskLayout -> SessionMobileLayout ->
SessionMobileTopBar -> MobileTopBarActions so the mobile top bar
hides plugin content on archived tasks, matching the desktop
TopBarRight behavior fixed in the previous commit.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-07-20 18:52:56 +01:00
committed by GitHub
parent 15949959b0
commit b277e7f1d8
10 changed files with 357 additions and 38 deletions
@@ -41,6 +41,7 @@ type SessionMobileLayoutProps = {
remoteCreatedAt?: string | null;
remoteCheckedAt?: string | null;
remoteStatusError?: string | null;
isArchived?: boolean;
};
function MobileChatPanelContent({
@@ -201,6 +202,7 @@ type MobileTopBarStickyProps = {
remoteCreatedAt?: string | null;
remoteCheckedAt?: string | null;
remoteStatusError?: string | null;
isArchived?: boolean;
};
function MobileTopBarSticky(props: MobileTopBarStickyProps) {
@@ -226,6 +228,7 @@ function MobileTopBarSticky(props: MobileTopBarStickyProps) {
remoteCreatedAt={props.remoteCreatedAt}
remoteCheckedAt={props.remoteCheckedAt}
remoteStatusError={props.remoteStatusError}
isArchived={props.isArchived}
/>
</div>
);
@@ -373,6 +376,7 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
remoteCreatedAt,
remoteCheckedAt,
remoteStatusError,
isArchived,
}: SessionMobileLayoutProps) {
const {
activeTaskId,
@@ -415,6 +419,7 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
remoteCreatedAt={remoteCreatedAt}
remoteCheckedAt={remoteCheckedAt}
remoteStatusError={remoteStatusError}
isArchived={isArchived}
/>
<MobilePanelArea
@@ -16,6 +16,7 @@ import {
useMobileGitActions,
} from "./session-mobile-top-bar-git-controls";
import { MobileRepoPill } from "./mobile-repo-pill";
import { TaskTopBarPluginActions } from "@/components/task/task-top-bar-plugin-actions";
type SessionMobileTopBarProps = {
taskId?: string | null;
@@ -34,6 +35,7 @@ type SessionMobileTopBarProps = {
remoteCreatedAt?: string | null;
remoteCheckedAt?: string | null;
remoteStatusError?: string | null;
isArchived?: boolean;
};
function MobileTaskTitle({
@@ -63,6 +65,56 @@ function MobileTaskTitle({
);
}
function RemoteExecutorIndicator({
taskId,
sessionId,
remoteExecutorType,
remoteExecutorName,
remoteState,
remoteCreatedAt,
remoteCheckedAt,
remoteStatusError,
}: {
taskId?: string | null;
sessionId?: string | null;
remoteExecutorType?: string | null;
remoteExecutorName?: string | null;
remoteState?: string | null;
remoteCreatedAt?: string | null;
remoteCheckedAt?: string | null;
remoteStatusError?: string | null;
}) {
return (
<RemoteCloudTooltip
taskId={taskId ?? ""}
sessionId={sessionId}
executorType={remoteExecutorType}
fallbackName={remoteExecutorName ?? remoteExecutorType}
iconClassName="h-4 w-4"
status={{
remote_name: remoteExecutorName ?? undefined,
remote_state: remoteState ?? undefined,
remote_created_at: remoteCreatedAt ?? undefined,
remote_checked_at: remoteCheckedAt ?? undefined,
remote_status_error: remoteStatusError ?? undefined,
}}
/>
);
}
function ApproveButton({ onApprove }: { onApprove: () => void }) {
return (
<Button
size="sm"
className="h-7 gap-1 px-2 cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white text-xs"
onClick={onApprove}
>
<IconCheck className="h-3.5 w-3.5" />
Approve
</Button>
);
}
type MobileTopBarActionsProps = {
taskId?: string | null;
workspaceId?: string | null;
@@ -79,6 +131,8 @@ type MobileTopBarActionsProps = {
isGitLoading: boolean;
uncommittedCount: number;
baseBranch?: string;
taskTitle?: string;
isArchived?: boolean;
onCommitClick: () => void;
onPRClick: () => void;
onPull: () => void;
@@ -104,6 +158,8 @@ function MobileTopBarActions({
isGitLoading,
uncommittedCount,
baseBranch,
taskTitle,
isArchived,
onCommitClick,
onPRClick,
onPull,
@@ -115,32 +171,27 @@ function MobileTopBarActions({
return (
<div className="flex items-center gap-1" data-testid="mobile-topbar-actions">
<MobileRepoPill taskId={taskId ?? null} workspaceId={workspaceId ?? null} />
{isRemoteExecutor && (
<RemoteCloudTooltip
taskId={taskId ?? ""}
sessionId={sessionId}
executorType={remoteExecutorType}
fallbackName={remoteExecutorName ?? remoteExecutorType}
iconClassName="h-4 w-4"
status={{
remote_name: remoteExecutorName ?? undefined,
remote_state: remoteState ?? undefined,
remote_created_at: remoteCreatedAt ?? undefined,
remote_checked_at: remoteCheckedAt ?? undefined,
remote_status_error: remoteStatusError ?? undefined,
}}
{!isArchived && (
<TaskTopBarPluginActions
sessionId={sessionId ?? null}
taskId={taskId ?? null}
taskTitle={taskTitle}
workspaceId={workspaceId ?? null}
/>
)}
{showApproveButton && onApprove && (
<Button
size="sm"
className="h-7 gap-1 px-2 cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white text-xs"
onClick={onApprove}
>
<IconCheck className="h-3.5 w-3.5" />
Approve
</Button>
{isRemoteExecutor && (
<RemoteExecutorIndicator
taskId={taskId}
sessionId={sessionId}
remoteExecutorType={remoteExecutorType}
remoteExecutorName={remoteExecutorName}
remoteState={remoteState}
remoteCreatedAt={remoteCreatedAt}
remoteCheckedAt={remoteCheckedAt}
remoteStatusError={remoteStatusError}
/>
)}
{showApproveButton && onApprove && <ApproveButton onApprove={onApprove} />}
<GitActionsDropdown
sessionId={sessionId}
isGitLoading={isGitLoading}
@@ -167,6 +218,62 @@ function MobileTopBarActions({
);
}
function MobileTopBarDialogs({
commitDialogOpen,
setCommitDialogOpen,
prDialogOpen,
setPrDialogOpen,
uncommittedCount,
uncommittedAdditions,
uncommittedDeletions,
isGitLoading,
onCommit,
displayBranch,
baseBranch,
taskTitle,
firstCommitMessage,
onCreatePR,
}: {
commitDialogOpen: boolean;
setCommitDialogOpen: (open: boolean) => void;
prDialogOpen: boolean;
setPrDialogOpen: (open: boolean) => void;
uncommittedCount: number;
uncommittedAdditions: number;
uncommittedDeletions: number;
isGitLoading: boolean;
onCommit: (message: string, stageAll: boolean) => Promise<void>;
displayBranch?: string;
baseBranch?: string;
taskTitle?: string;
firstCommitMessage?: string;
onCreatePR: (title: string, body: string, draft: boolean) => Promise<void>;
}) {
return (
<>
<CommitDialog
open={commitDialogOpen}
onOpenChange={setCommitDialogOpen}
uncommittedCount={uncommittedCount}
uncommittedAdditions={uncommittedAdditions}
uncommittedDeletions={uncommittedDeletions}
isGitLoading={isGitLoading}
onCommit={onCommit}
/>
<PRDialog
open={prDialogOpen}
onOpenChange={setPrDialogOpen}
displayBranch={displayBranch}
baseBranch={baseBranch}
isGitLoading={isGitLoading}
taskTitle={taskTitle}
firstCommitMessage={firstCommitMessage}
onCreatePR={onCreatePR}
/>
</>
);
}
export const SessionMobileTopBar = memo(function SessionMobileTopBar({
taskId,
workspaceId,
@@ -184,6 +291,7 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar({
remoteCreatedAt,
remoteCheckedAt,
remoteStatusError,
isArchived,
}: SessionMobileTopBarProps) {
const [commitDialogOpen, setCommitDialogOpen] = useState(false);
const [prDialogOpen, setPrDialogOpen] = useState(false);
@@ -240,6 +348,8 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar({
isGitLoading={isGitLoading}
uncommittedCount={uncommittedCount}
baseBranch={baseBranch}
taskTitle={taskTitle}
isArchived={isArchived}
onCommitClick={() => setCommitDialogOpen(true)}
onPRClick={() => setPrDialogOpen(true)}
onPull={handlePull}
@@ -248,21 +358,18 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar({
onMerge={handleMerge}
onMenuClick={onMenuClick}
/>
<CommitDialog
open={commitDialogOpen}
onOpenChange={setCommitDialogOpen}
<MobileTopBarDialogs
commitDialogOpen={commitDialogOpen}
setCommitDialogOpen={setCommitDialogOpen}
prDialogOpen={prDialogOpen}
setPrDialogOpen={setPrDialogOpen}
uncommittedCount={uncommittedCount}
uncommittedAdditions={uncommittedAdditions}
uncommittedDeletions={uncommittedDeletions}
isGitLoading={isGitLoading}
onCommit={handleCommit}
/>
<PRDialog
open={prDialogOpen}
onOpenChange={setPrDialogOpen}
displayBranch={displayBranch}
baseBranch={baseBranch}
isGitLoading={isGitLoading}
taskTitle={taskTitle}
firstCommitMessage={commits[0]?.commit_message}
onCreatePR={handleCreatePR}
+3
View File
@@ -36,6 +36,7 @@ type TaskLayoutProps = {
remoteCheckedAt?: string | null;
remoteStatusError?: string | null;
initialLayout?: string | null;
isArchived?: boolean;
};
export const TaskLayout = memo(function TaskLayout({
@@ -57,6 +58,7 @@ export const TaskLayout = memo(function TaskLayout({
remoteCheckedAt,
remoteStatusError,
initialLayout,
isArchived,
}: TaskLayoutProps) {
const { isMobile, usesDesktopWorkbench, isFullDesktop } = useResponsiveBreakpoint();
@@ -77,6 +79,7 @@ export const TaskLayout = memo(function TaskLayout({
remoteCreatedAt={remoteCreatedAt}
remoteCheckedAt={remoteCheckedAt}
remoteStatusError={remoteStatusError}
isArchived={isArchived}
/>
);
}
@@ -161,6 +161,7 @@ function buildTaskLayoutProps(params: {
remoteCreatedAt: params.remote.remoteCreatedAt,
remoteCheckedAt: params.remote.remoteCheckedAt,
remoteStatusError: params.remote.remoteStatusError,
isArchived: taskProps.isArchived,
};
}
@@ -0,0 +1,85 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { pluginRegistry } from "@/lib/plugins/registry";
import { TaskTopBarPluginActions, type ChatTopBarSlotProps } from "./task-top-bar-plugin-actions";
const SLOT = "chat-top-bar";
// Minimal store: the wrapper only reads taskSessionsByTask.itemsByTaskId.
const mockState = {
taskSessionsByTask: {
itemsByTaskId: {
t1: [
{ id: "s1", task_id: "t1" },
{ id: "s2", task_id: "t1" },
],
},
},
};
vi.mock("@/components/state-provider", () => ({
useOptionalAppStore: (selector: (s: typeof mockState) => unknown) => selector(mockState),
}));
describe("TaskTopBarPluginActions", () => {
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin("plugin-a");
});
it("renders nothing when no plugin registered a chat-top-bar component", () => {
const { container } = render(
<TaskTopBarPluginActions sessionId="s1" taskId="t1" taskTitle="Demo" workspaceId="w1" />,
);
expect(container.innerHTML).toBe("");
});
it("forwards task/workspace context, the active session, and all session ids", () => {
pluginRegistry.forPlugin("plugin-a").registerComponent(SLOT, ({ slotProps }) => {
const ctx = slotProps as ChatTopBarSlotProps;
return (
<div data-testid="plugin-topbar">
{`${ctx.taskId}|${ctx.workspaceId}|${ctx.activeSessionId}|${ctx.sessionIds.join(",")}`}
</div>
);
});
render(
<TaskTopBarPluginActions sessionId="s2" taskId="t1" taskTitle="Demo" workspaceId="w1" />,
);
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 <div data-testid="plugin-topbar">{ctx.sessionIds.join(",")}</div>;
});
// taskId with no store entry -> only the active session propagates.
render(
<TaskTopBarPluginActions
sessionId="s9"
taskId="t-unknown"
taskTitle="Demo"
workspaceId="w1"
/>,
);
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 (
<div data-testid="plugin-topbar">{`${ctx.taskId}|${ctx.activeSessionId}|${ctx.sessionIds.join(",")}`}</div>
);
});
render(<TaskTopBarPluginActions sessionId="s9" taskId={null} workspaceId={null} />);
expect(screen.getByTestId("plugin-topbar").textContent).toBe("null|s9|s9");
});
});
@@ -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<ChatTopBarSlotProps>(() => {
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 <PluginSlot name="chat-top-bar" slotProps={slotProps} />;
}
+11
View File
@@ -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 (
<div className="flex items-center justify-self-end gap-2 [&_button]:whitespace-nowrap">
<TopbarMetrics activeSessionId={activeSessionId} size="sm" />
{!isArchived && (
<TopbarCluster label="Plugin top bar actions" className="[&_button]:h-7 [&_button]:text-xs">
<TaskTopBarPluginActions
sessionId={activeSessionId ?? null}
taskId={taskId ?? null}
taskTitle={taskTitle}
workspaceId={workspaceId ?? null}
/>
</TopbarCluster>
)}
{isArchived && (
<TopbarCluster label="Unarchive task" className="[&_button]:h-7 [&_button]:text-xs">
<TaskUnarchiveButton taskId={taskId} onUnarchived={onTaskUnarchived} />
+4 -2
View File
@@ -71,10 +71,12 @@ export interface PluginRouteOptions {
/**
* Named slot the host renders via `<PluginSlot name .../>`. 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;
+9 -5
View File
@@ -123,11 +123,15 @@ interface PluginRegistry {
// Named slot injection. Host renders all components registered for a slot via
// <PluginSlot name="..." props={...}/>. 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;
+29
View File
@@ -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