254 lines
8.9 KiB
TypeScript
254 lines
8.9 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* The status row above the chat composer: todos, the autopilot / dependency /
|
|
* PR / MR chips, the queue chip, archive banners, transcript navigation, and
|
|
* the proceed button.
|
|
*
|
|
* Split out of `chat-input-area.tsx`, which was at its 600-line limit. The row
|
|
* is a self-contained unit: it reads the task and session ids and renders
|
|
* indicators, so nothing here participates in composing or sending a message.
|
|
*/
|
|
|
|
import { useMemo, type ReactNode } from "react";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { WorkflowMoveProceedButton } from "@/components/task/workflow-move-proceed-button";
|
|
import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api";
|
|
import { PRStatusChip } from "@/components/github/pr-status-chip";
|
|
import { MRStatusChip } from "@/components/gitlab/mr-status-chip";
|
|
import { TaskDependencyChip } from "@/components/task/task-dependency-chip";
|
|
import { AzureDevOpsTaskPullRequestChip } from "@/components/azure-devops/azure-devops-task-pull-request-chip";
|
|
import { RegisteredChangeRequestStatus } from "@/components/integrations/registered-change-request-status";
|
|
import { shareableSessionStateClient } from "@/components/task/share/share-button";
|
|
import { TranscriptNavGroup } from "@/components/task/chat/transcript-nav-group";
|
|
import { OpenInThreadsButton } from "@/components/threads/open-in-threads-button";
|
|
import { useIsDeckThread } from "@/hooks/domains/threads/use-deck-thread";
|
|
import { TodoIndicator } from "./todo-indicator";
|
|
import { AutoScrollToggleButton } from "./auto-scroll-toggle-button";
|
|
import { PRMergedBanner, PRClosedBanner } from "./pr-archive-banners";
|
|
import { AutopilotChatChip, useTaskAutopilot } from "./task-autopilot-chat-chip";
|
|
import { AgentGoalChip } from "./agent-goal-chip";
|
|
import { shouldShowProceed } from "./types";
|
|
import { getAgentGoal } from "@/lib/agent-goal";
|
|
import { useComposerDisclosureContext } from "./composer-disclosure";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type TodoDisplayItem = {
|
|
text: string;
|
|
done?: boolean;
|
|
status?: "pending" | "in_progress" | "completed" | "failed";
|
|
};
|
|
|
|
/**
|
|
* The status row belongs to the TASK, not to the session.
|
|
*
|
|
* `panelState.taskId` is session-derived and is null on a task that has no
|
|
* session yet, which is exactly the state a blocked dependency-chain step sits
|
|
* in: with no id the whole row is hidden, taking the dependency chip with it on
|
|
* the one kind of task the chip is about. Hosts that know their task pass it as
|
|
* `statusTaskId`. The session-derived id still wins when present, so hosts that
|
|
* pass nothing are unchanged.
|
|
*/
|
|
export function resolveStatusRowTaskId(
|
|
sessionTaskId: string | null,
|
|
statusTaskId: string | null,
|
|
): string | null {
|
|
return sessionTaskId ?? statusTaskId;
|
|
}
|
|
|
|
export function shouldRenderChatStatusBar({
|
|
hasTask,
|
|
hasTodos,
|
|
hasQueueChip,
|
|
showRightControls,
|
|
showProceed,
|
|
hasGoal,
|
|
}: {
|
|
hasTask: boolean;
|
|
hasTodos: boolean;
|
|
hasQueueChip: boolean;
|
|
showRightControls: boolean;
|
|
showProceed: boolean;
|
|
hasGoal?: boolean;
|
|
}): boolean {
|
|
return hasTask || hasTodos || hasQueueChip || showRightControls || showProceed || !!hasGoal;
|
|
}
|
|
|
|
function readACPMetadata(metadata: Record<string, unknown> | null | undefined): unknown {
|
|
const acp = metadata?.acp;
|
|
if (!acp || typeof acp !== "object" || Array.isArray(acp)) return undefined;
|
|
return (acp as Record<string, unknown>).meta;
|
|
}
|
|
|
|
function getRightControlVisibility({
|
|
taskId,
|
|
sessionId,
|
|
sessionState,
|
|
showAutoScrollControl,
|
|
showScrollToLastPrompt,
|
|
showScrollToStart,
|
|
showThreadsLink,
|
|
}: {
|
|
taskId: string | null;
|
|
sessionId: string | null;
|
|
sessionState: string | null;
|
|
showAutoScrollControl: boolean;
|
|
showScrollToLastPrompt: boolean | undefined;
|
|
showScrollToStart: boolean | undefined;
|
|
showThreadsLink: boolean;
|
|
}) {
|
|
const canShare = !!taskId && !!sessionId && shareableSessionStateClient(sessionState);
|
|
const showRightControls =
|
|
(showAutoScrollControl && !!sessionId) ||
|
|
canShare ||
|
|
showThreadsLink ||
|
|
!!showScrollToLastPrompt ||
|
|
!!showScrollToStart;
|
|
return { canShare, showRightControls };
|
|
}
|
|
|
|
/**
|
|
* Row above the composer showing todo progress, PR/CI status chips,
|
|
* merged/closed PR banners, the auto-scroll toggle + Share (right-aligned),
|
|
* and a "move to next step" action when the workflow allows it.
|
|
*/
|
|
export type ChatStatusBarProps = {
|
|
todoItems: TodoDisplayItem[];
|
|
taskId: string | null;
|
|
sessionId: string | null;
|
|
sessionState: string | null;
|
|
nextStepName: string | null;
|
|
onProceed: (options?: WorkflowMoveEntryOptions) => boolean | void | Promise<boolean | void>;
|
|
isAgentBusy: boolean;
|
|
hasPendingClarification: boolean;
|
|
isMoving: boolean;
|
|
queueChip?: ReactNode;
|
|
showScrollToLastPrompt?: boolean;
|
|
onScrollToLastPrompt?: () => void;
|
|
lastPromptScrollDirection?: "up" | "down";
|
|
showScrollToStart?: boolean;
|
|
onScrollToStart?: () => void;
|
|
};
|
|
|
|
export function ComposerCIStatus({
|
|
taskId,
|
|
sessionId,
|
|
standalone = false,
|
|
}: {
|
|
taskId: string | null;
|
|
sessionId: string | null;
|
|
standalone?: boolean;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={cn("flex flex-wrap items-center gap-1.5 empty:hidden", standalone && "px-2 py-1")}
|
|
>
|
|
<PRStatusChip taskId={taskId} />
|
|
<MRStatusChip taskId={taskId} />
|
|
<AzureDevOpsTaskPullRequestChip taskId={taskId} />
|
|
<RegisteredChangeRequestStatus taskId={taskId} sessionId={sessionId} surface="composer" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ChatStatusBar({
|
|
todoItems,
|
|
taskId,
|
|
sessionId,
|
|
sessionState,
|
|
nextStepName,
|
|
onProceed,
|
|
isAgentBusy,
|
|
hasPendingClarification,
|
|
isMoving,
|
|
queueChip,
|
|
showScrollToLastPrompt,
|
|
onScrollToLastPrompt,
|
|
lastPromptScrollDirection,
|
|
showScrollToStart,
|
|
onScrollToStart,
|
|
}: ChatStatusBarProps) {
|
|
const separateCI = useComposerDisclosureContext()?.enabled;
|
|
const showTodos = todoItems.length > 0;
|
|
const showProceed = shouldShowProceed(nextStepName, isAgentBusy, hasPendingClarification);
|
|
const autopilot = useTaskAutopilot(taskId);
|
|
const showAutoScrollControl = useAppStore(
|
|
(state) => state.userSettings.showTranscriptAutoScrollControl,
|
|
);
|
|
// Asked here rather than inside the button so the cluster still renders when
|
|
// the Threads jump is the only right-hand control this session qualifies for.
|
|
const showThreadsLink = useIsDeckThread(taskId, sessionId);
|
|
const activeGoalMetadata = useAppStore((state) =>
|
|
sessionId ? readACPMetadata(state.taskSessions.items[sessionId]?.metadata) : undefined,
|
|
);
|
|
const activeGoal = useMemo(() => {
|
|
const goal = getAgentGoal(activeGoalMetadata);
|
|
return goal?.status === "active" ? goal : null;
|
|
}, [activeGoalMetadata]);
|
|
const { canShare, showRightControls } = getRightControlVisibility({
|
|
taskId,
|
|
sessionId,
|
|
sessionState,
|
|
showAutoScrollControl,
|
|
showScrollToLastPrompt,
|
|
showScrollToStart,
|
|
showThreadsLink,
|
|
});
|
|
if (
|
|
!shouldRenderChatStatusBar({
|
|
hasTask: !!taskId,
|
|
hasTodos: showTodos,
|
|
hasQueueChip: !!queueChip,
|
|
showRightControls,
|
|
showProceed,
|
|
hasGoal: !!activeGoal,
|
|
})
|
|
) {
|
|
return null;
|
|
}
|
|
// PRMergedBanner returns null internally when not applicable
|
|
return (
|
|
<div
|
|
data-testid="chat-status-bar"
|
|
className="flex min-w-0 flex-wrap items-center gap-1.5 py-1 text-xs text-muted-foreground"
|
|
>
|
|
{showTodos && <TodoIndicator todos={todoItems} />}
|
|
{autopilot && <AutopilotChatChip />}
|
|
<TaskDependencyChip taskId={taskId} />
|
|
{!separateCI && <ComposerCIStatus taskId={taskId} sessionId={sessionId} />}
|
|
{activeGoal && <AgentGoalChip key={sessionId ?? "none"} goal={activeGoal} />}
|
|
{queueChip}
|
|
{/* Distinct per-banner keys: the key remounts the banner on task switch
|
|
so its dismissed state re-initialises, and keeping the two suffixes
|
|
different avoids a duplicate-sibling-key collision. */}
|
|
{taskId && <PRMergedBanner key={`${taskId}-merged`} taskId={taskId} />}
|
|
{taskId && <PRClosedBanner key={`${taskId}-closed`} taskId={taskId} />}
|
|
{showRightControls && (
|
|
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
|
<OpenInThreadsButton taskId={taskId} sessionId={sessionId} />
|
|
{sessionId && <AutoScrollToggleButton sessionId={sessionId} />}
|
|
<TranscriptNavGroup
|
|
canShare={canShare}
|
|
taskId={taskId}
|
|
sessionId={sessionId}
|
|
showScrollToLastPrompt={showScrollToLastPrompt}
|
|
onScrollToLastPrompt={onScrollToLastPrompt}
|
|
lastPromptScrollDirection={lastPromptScrollDirection}
|
|
showScrollToStart={showScrollToStart}
|
|
onScrollToStart={onScrollToStart}
|
|
/>
|
|
</div>
|
|
)}
|
|
{showProceed && nextStepName && (
|
|
<WorkflowMoveProceedButton
|
|
nextStepName={nextStepName}
|
|
onProceed={onProceed}
|
|
isMoving={isMoving}
|
|
className={`${showRightControls ? "" : "ml-auto "}h-6`}
|
|
testId="proceed-next-step"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|