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:
@@ -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}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user