diff --git a/apps/web/components/task/mobile/session-mobile-layout.tsx b/apps/web/components/task/mobile/session-mobile-layout.tsx index c0692b57f4..4751b32553 100644 --- a/apps/web/components/task/mobile/session-mobile-layout.tsx +++ b/apps/web/components/task/mobile/session-mobile-layout.tsx @@ -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} /> ); @@ -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} /> + ); +} + +function ApproveButton({ onApprove }: { onApprove: () => void }) { + return ( + + ); +} + 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 (
- {isRemoteExecutor && ( - )} - {showApproveButton && onApprove && ( - + {isRemoteExecutor && ( + )} + {showApproveButton && onApprove && } void; + prDialogOpen: boolean; + setPrDialogOpen: (open: boolean) => void; + uncommittedCount: number; + uncommittedAdditions: number; + uncommittedDeletions: number; + isGitLoading: boolean; + onCommit: (message: string, stageAll: boolean) => Promise; + displayBranch?: string; + baseBranch?: string; + taskTitle?: string; + firstCommitMessage?: string; + onCreatePR: (title: string, body: string, draft: boolean) => Promise; +}) { + return ( + <> + + + + ); +} + 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} /> - - ); } diff --git a/apps/web/components/task/task-page-inner.tsx b/apps/web/components/task/task-page-inner.tsx index 3d060106ac..0a3e2c4fc8 100644 --- a/apps/web/components/task/task-page-inner.tsx +++ b/apps/web/components/task/task-page-inner.tsx @@ -161,6 +161,7 @@ function buildTaskLayoutProps(params: { remoteCreatedAt: params.remote.remoteCreatedAt, remoteCheckedAt: params.remote.remoteCheckedAt, remoteStatusError: params.remote.remoteStatusError, + isArchived: taskProps.isArchived, }; } diff --git a/apps/web/components/task/task-top-bar-plugin-actions.test.tsx b/apps/web/components/task/task-top-bar-plugin-actions.test.tsx new file mode 100644 index 0000000000..0a21558c02 --- /dev/null +++ b/apps/web/components/task/task-top-bar-plugin-actions.test.tsx @@ -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( + , + ); + 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 ( +
+ {`${ctx.taskId}|${ctx.workspaceId}|${ctx.activeSessionId}|${ctx.sessionIds.join(",")}`} +
+ ); + }); + + 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 ( +
{`${ctx.taskId}|${ctx.activeSessionId}|${ctx.sessionIds.join(",")}`}
+ ); + }); + + 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