Files
kandev/apps/web/components/task/chat/clarification-panel-section.tsx
T
2026-09-15 19:05:40 +01:00

235 lines
8.2 KiB
TypeScript

"use client";
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import { IconChevronDown, IconChevronUp, IconMessageQuestion } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { useTranslation } from "react-i18next";
import { ClarificationInputOverlay } from "./clarification-input-overlay";
import { useComposerDisclosureContext } from "./composer-disclosure";
import { ResizeHandle } from "./resize-handle";
import { useResizableClarificationOverlay } from "@/hooks/use-resizable-clarification-overlay";
import type { ClarificationRequestMetadata, Message } from "@/lib/types/http";
import type { ClarificationOutcome } from "@/hooks/domains/session/use-clarification-group";
type ClarificationPanelSectionProps = {
pending: boolean;
messages: readonly Message[] | null | undefined;
onResolved: () => void;
shortcutScopeRef: RefObject<HTMLElement | null>;
/**
* True when the session no longer has a live clarification waiter.
* Detached requests remain answerable even if their row refresh lags.
*/
agentDisconnected?: boolean;
/**
* Caps the expanded overlay's height as a percentage of the viewport.
* Drives both the rendered CSS max-height and the resize hook's drag clamp
* - the two MUST agree, or the drag handle silently stops responding before
* its own visible ceiling.
*/
maxHeightVh: number;
// Additive: forwarded straight through to ClarificationInputOverlay.
// Existing hosts (task chat, Quick Chat) leave this unset.
onOutcome?: (outcome: ClarificationOutcome) => void;
};
function pendingIdFromMessages(messages: readonly Message[] | null | undefined): string | null {
const first = messages?.[0];
if (!first) return null;
const metadata = first.metadata as ClarificationRequestMetadata | undefined;
return metadata?.pending_id ?? null;
}
// Tracks collapse state per bundle: a bundle the user dismissed stays
// collapsed while it's still pending, but a *new* bundle (different
// pending_id) always opens expanded, even if the previous one never resolved.
function useCollapsedForBundle(pendingId: string | null) {
const [collapsed, setCollapsed] = useState(false);
const lastPendingId = useRef(pendingId);
useEffect(() => {
if (pendingId !== lastPendingId.current) {
lastPendingId.current = pendingId;
setCollapsed(false);
}
}, [pendingId]);
return [collapsed, setCollapsed] as const;
}
/**
* Collapsible clarification section shared by the task chat panel and Quick
* Chat. Escape (or the labelled toggle) collapses this to a persistent header
* bar instead of answering or rejecting the bundle, so a blocked agent stays
* visibly blocked and the question stays reachable until answered or skipped.
*/
export function ClarificationPanelSection({
pending,
messages,
onResolved,
shortcutScopeRef,
agentDisconnected = false,
maxHeightVh,
onOutcome,
}: ClarificationPanelSectionProps) {
const { t } = useTranslation();
const disclosure = useComposerDisclosureContext();
const pendingId = pendingIdFromMessages(messages);
const [collapsed, setCollapsed] = useCollapsedForBundle(pendingId);
const contentId = useId();
const { height, containerRef, resetHeight, resizeHandleProps } = useResizableClarificationOverlay(
maxHeightVh / 100,
);
// A newly opened clarification starts expanded and auto-sized. Collapsing
// an active one leaves its form state and user-selected height intact.
useEffect(() => {
if (!pending) resetHeight();
}, [pending, resetHeight]);
// A new bundle (different pending_id) replacing a still-pending one must
// not inherit the previous bundle's dragged height.
useEffect(() => {
resetHeight();
}, [pendingId, resetHeight]);
if (!pending) return null;
const questionCount = messages?.length ?? 0;
const actionLabel = collapsed ? t("chat:expandClarification") : t("chat:collapseClarification");
return (
<ClarificationPanelContent
actionLabel={actionLabel}
agentDisconnected={agentDisconnected}
collapsed={collapsed}
containerRef={containerRef}
contentId={contentId}
disclosure={disclosure}
height={height}
maxHeightVh={maxHeightVh}
messages={messages}
onCollapse={() => setCollapsed(true)}
onOutcome={onOutcome}
onResolved={onResolved}
onToggleCollapse={() => setCollapsed((current) => !current)}
questionCount={questionCount}
resizeHandleProps={resizeHandleProps}
shortcutScopeRef={shortcutScopeRef}
/>
);
}
type ClarificationPanelContentProps = {
actionLabel: string;
agentDisconnected: boolean;
collapsed: boolean;
containerRef: RefObject<HTMLDivElement | null>;
contentId: string;
disclosure: ReturnType<typeof useComposerDisclosureContext>;
height: number | null;
maxHeightVh: number;
messages: readonly Message[] | null | undefined;
onCollapse: () => void;
onOutcome?: (outcome: ClarificationOutcome) => void;
onResolved: () => void;
onToggleCollapse: () => void;
questionCount: number;
resizeHandleProps: Parameters<typeof ResizeHandle>[0];
shortcutScopeRef: RefObject<HTMLElement | null>;
};
function ClarificationPanelContent({
actionLabel,
agentDisconnected,
collapsed,
containerRef,
contentId,
disclosure,
height,
maxHeightVh,
messages,
onCollapse,
onOutcome,
onResolved,
onToggleCollapse,
questionCount,
resizeHandleProps,
shortcutScopeRef,
}: ClarificationPanelContentProps) {
const { t } = useTranslation();
const compact = collapsed || questionCount === 0;
return (
<div className="relative flex-shrink-0 border-t border-sky-400/30 bg-card">
{!compact && <ResizeHandle {...resizeHandleProps} />}
<div
ref={containerRef}
data-testid="clarification-overlay-container"
className={
compact ? "h-11" : "flex min-h-[7.5rem] flex-col overflow-hidden overscroll-contain"
}
style={
compact
? undefined
: {
maxHeight: `${maxHeightVh}vh`,
// Threads owns the outer vertical scroll boundary in its bounded footer.
overscrollBehaviorY: disclosure ? "auto" : undefined,
...(height !== null ? { height } : {}),
}
}
>
{compact && (
<div className="flex h-11 flex-shrink-0 items-center justify-between gap-2 pl-4">
<div className="flex min-w-0 items-center gap-2 text-sm font-medium">
<IconMessageQuestion className="h-4 w-4 flex-shrink-0 text-blue-500" />
<span className="truncate">{t("chat:clarificationNeeded")}</span>
{questionCount > 0 && (
<span
data-testid="clarification-question-count"
className="truncate text-xs font-normal text-muted-foreground"
>
{t("task:questionCount", { count: questionCount })}
</span>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11 flex-shrink-0 cursor-pointer rounded-none"
aria-label={actionLabel}
aria-expanded={!collapsed}
aria-controls={contentId}
title={actionLabel}
data-testid="clarification-collapse-toggle"
onClick={onToggleCollapse}
>
{collapsed ? (
<IconChevronUp className="h-4 w-4" />
) : (
<IconChevronDown className="h-4 w-4" />
)}
</Button>
</div>
)}
<div
id={contentId}
data-testid="clarification-scroll-region"
className={compact ? "hidden" : "min-h-0 flex-1 overflow-y-auto px-1"}
>
<ClarificationInputOverlay
messages={messages}
onResolved={onResolved}
onOutcome={onOutcome}
shortcutScopeRef={shortcutScopeRef}
keyboardShortcutsEnabled={!collapsed}
agentDisconnected={agentDisconnected}
onDismiss={onCollapse}
onCollapse={onCollapse}
collapseContentId={contentId}
/>
</div>
</div>
</div>
);
}