feat(agent): support Responses steering and project-aware tools
Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running turns active while users steer or queue follow-ups and edit pending delivery. Refresh the model catalog before generation and preserve approval mode selection. Integrate scoped investigation messaging/presentation, geographic place selection, current Agent composer styles and supporting workbench contracts. Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useId, useRef, useState, type ReactNode, type Ref } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { Maximize2, X } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
|
||||
/** Report-specific progressive disclosure. The source stays mounted while reading. */
|
||||
export function InvestigationExpandCard({
|
||||
title, label = title, preview, children, wide = false, onOpen, contentRef,
|
||||
triggerLabel = `展开${title}`, className, revision, disabled = false, reuseContent = false
|
||||
}: {
|
||||
title: string;
|
||||
label?: string;
|
||||
preview: ReactNode;
|
||||
children: ReactNode | ((close: (restoreFocus?: boolean) => void) => ReactNode);
|
||||
wide?: boolean;
|
||||
onOpen?: () => void;
|
||||
contentRef?: Ref<HTMLElement>;
|
||||
triggerLabel?: string;
|
||||
className?: string;
|
||||
/** Freeze versioned prose while reading; live controls must not use this. */
|
||||
revision?: string;
|
||||
disabled?: boolean;
|
||||
/** Move one live content host between slots, preserving expensive renderers. */
|
||||
reuseContent?: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const restoreFocusRef = useRef(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [keyboard, setKeyboard] = useState(false);
|
||||
const [reading, setReading] = useState<{ revision: string; content: typeof children } | null>(null);
|
||||
const [contentHost] = useState(() => reuseContent ? document.createElement("div") : null);
|
||||
const previewSlot = useRef<HTMLDivElement | null>(null);
|
||||
const attachPreview = useCallback((slot: HTMLDivElement | null) => {
|
||||
previewSlot.current = slot;
|
||||
if (slot && contentHost && !contentHost.isConnected) slot.appendChild(contentHost);
|
||||
}, [contentHost]);
|
||||
const attachDetail = useCallback((slot: HTMLDivElement | null) => {
|
||||
const preview = previewSlot.current;
|
||||
if (!preview || !contentHost) return;
|
||||
if (slot) {
|
||||
// Reserve only the vacated inline space while expanded. The graph itself
|
||||
// keeps its natural height, including after content updates or resizing.
|
||||
// Use the layout height, not a bounding box scaled by the card animation.
|
||||
preview.style.height = getComputedStyle(preview).height;
|
||||
slot.appendChild(contentHost);
|
||||
} else {
|
||||
preview.appendChild(contentHost);
|
||||
preview.style.removeProperty("height");
|
||||
}
|
||||
}, [contentHost]);
|
||||
const reduced = useReducedMotion();
|
||||
const duration = reduced || keyboard ? 0 : 0.24;
|
||||
const transition = { duration, ease: [0.22, 1, 0.36, 1] as const };
|
||||
const close = (restoreFocus = true) => { restoreFocusRef.current = restoreFocus; setOpen(false); };
|
||||
const content = reading?.content ?? children;
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (next) {
|
||||
restoreFocusRef.current = true;
|
||||
setReading(revision === undefined ? null : { revision, content: children });
|
||||
onOpen?.();
|
||||
}
|
||||
}}>
|
||||
<motion.section
|
||||
ref={contentRef}
|
||||
aria-label={label}
|
||||
className={cn("investigation-report-section investigation-expand-card", className)}
|
||||
layoutId={reduced || keyboard ? undefined : id}
|
||||
layoutDependency={open}
|
||||
transition={transition}
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<div className="investigation-card-heading">
|
||||
<h3 className="text-sm font-semibold text-[var(--ink)]">{title}</h3>
|
||||
<Dialog.Trigger asChild>
|
||||
<Button ref={triggerRef} variant="ghost" size="icon-sm" aria-label={triggerLabel} disabled={disabled}
|
||||
title={triggerLabel} onClick={(event) => setKeyboard(event.detail === 0)}>
|
||||
<Maximize2 size={15} aria-hidden />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
</div>
|
||||
{/* Contain child margins so the occupied and vacated slots measure alike. */}
|
||||
{contentHost ? <div ref={attachPreview} className="flow-root" /> : preview}
|
||||
</motion.section>
|
||||
{contentHost ? createPortal(typeof children === "function" ? children(close) : children, contentHost) : null}
|
||||
<Dialog.Portal forceMount>
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
<Dialog.Overlay forceMount asChild key="backdrop">
|
||||
<motion.div className="investigation-detail-backdrop"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={transition} />
|
||||
</Dialog.Overlay>
|
||||
) : null}
|
||||
{open ? (
|
||||
<div className="investigation-detail-positioner" key="detail">
|
||||
<Dialog.Content forceMount asChild aria-describedby={undefined}
|
||||
onEscapeKeyDown={(event) => { event.stopPropagation(); setKeyboard(true); }}
|
||||
onCloseAutoFocus={(event) => { event.preventDefault(); if (restoreFocusRef.current) triggerRef.current?.focus({ preventScroll: true }); }}>
|
||||
<motion.section className={cn("investigation-detail-panel", wide && "investigation-detail-panel-wide")}
|
||||
layoutId={reduced || keyboard ? undefined : id} layoutDependency={open}
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
transition={transition} style={{ borderRadius: 12 }}>
|
||||
<header className="investigation-detail-heading">
|
||||
<Dialog.Title className="text-base font-semibold text-[var(--ink)]">{title}</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="关闭详情"><X size={17} aria-hidden /></Button>
|
||||
</Dialog.Close>
|
||||
</header>
|
||||
<div className="investigation-detail-scroll">
|
||||
{contentHost ? <div ref={attachDetail} /> : typeof content === "function" ? content(close) : content}
|
||||
</div>
|
||||
{reading && revision !== undefined && reading.revision !== revision ? (
|
||||
<div className="investigation-report-update" role="status">
|
||||
<span>已有更新,当前保留正在阅读的版本。</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setReading({ revision, content: children })}>查看最新内容</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</motion.section>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user