Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

247 lines
8.3 KiB
TypeScript

import { useCallback, useRef, useState, type CSSProperties } from "react";
import { MessageCirclePlus } from "lucide-react";
import { PanelIcon } from "@tutti-os/ui-system";
import { ScrollArea } from "@tutti-os/ui-system/components";
import type { AgentSideConversationViewState } from "../../../agentSideConversationViewProjection";
import type { AgentMessageMarkdownWorkspaceAppIcon } from "../../../shared/AgentMessageMarkdown";
import type { WorkspaceLinkAction } from "../../../actions/workspaceLinkActions";
import type { AgentTranscriptVirtualScrollController } from "../../../shared/agentConversation/components/AgentTranscriptView";
import { AgentComposer } from "../AgentComposer";
import type { AgentComposerProps } from "../composer/AgentComposer.types";
import type { AgentGUIProviderSkillOption } from "../model/agentGuiNodeTypes";
import { AgentGUIConversationTimelinePane } from "./AgentGUIConversationTimelinePane";
import { useTranslation } from "../../../i18n/index";
import styles from "../AgentGUINode.styles";
const SIDE_TIMELINE_CONTENT_STYLE: CSSProperties = {
width: "100%",
minWidth: "100%",
minHeight: "100%",
display: "grid",
gridTemplateColumns: "minmax(0, 1fr)",
gap: "24px"
};
export interface AgentGUISideConversationPaneProps {
active: AgentSideConversationViewState;
availableSkills: readonly AgentGUIProviderSkillOption[];
composerProps: AgentComposerProps;
conversationFlowLabels: {
thinkingLabel: string;
toolCallsLabel: (count: number) => string;
processing: string;
turnSummary: string;
userMessageLocator: string;
};
isVisible: boolean;
loadingLabel: string;
workspaceAppIcons: readonly AgentMessageMarkdownWorkspaceAppIcon[];
onClose(): void | Promise<void>;
onFocusChange(focused: boolean): void;
onLinkAction?: (action: WorkspaceLinkAction) => void;
}
export type AgentGUISideConversationSurfaceProps = Omit<
AgentGUISideConversationPaneProps,
"onClose"
>;
export function AgentGUISideConversationSurface({
active,
availableSkills,
composerProps,
conversationFlowLabels,
isVisible,
loadingLabel,
workspaceAppIcons,
onFocusChange,
onLinkAction
}: AgentGUISideConversationSurfaceProps): React.JSX.Element {
const { t } = useTranslation();
const timelineRef = useRef<HTMLDivElement | null>(null);
const timelineContentRef = useRef<HTMLDivElement | null>(null);
const virtualScrollControllerRef =
useRef<AgentTranscriptVirtualScrollController | null>(null);
const [followEndMode, setFollowEndMode] = useState<"following" | "detached">(
"following"
);
const sideSurfaceRef = useCallback(
(node: HTMLElement | null) => {
if (!node || !isVisible) onFocusChange(false);
},
[isVisible, onFocusChange]
);
return (
<section
ref={sideSurfaceRef}
className={styles.sideSurface}
aria-label={t("agentHost.agentGui.sidePanelTitle")}
data-testid="agent-gui-side-panel"
onFocusCapture={() => onFocusChange(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
onFocusChange(false);
}
}}
>
<ScrollArea
scrollbarMode="native"
className={styles.sideTimelineFrame}
viewportRef={timelineRef}
viewportContentRef={timelineContentRef}
viewportTestId="agent-gui-side-timeline"
viewportClassName={styles.sideTimeline}
viewportContentStyle={SIDE_TIMELINE_CONTENT_STYLE}
viewportProps={{
onScroll: (event) => {
const viewport = event.currentTarget;
const distance =
viewport.scrollHeight -
viewport.clientHeight -
viewport.scrollTop;
setFollowEndMode(distance <= 24 ? "following" : "detached");
}
}}
>
<AgentGUIConversationTimelinePane
conversation={active.conversation}
followEndMode={followEndMode}
isLoading={active.status === "opening"}
isLoadingOlderMessages={false}
isVisible={isVisible}
loadingLabel={loadingLabel}
empty={
<div
className={styles.sideEmptyState}
data-testid="agent-gui-side-empty-state"
>
<MessageCirclePlus
aria-hidden="true"
className={styles.sideEmptyStateIcon}
size={30}
strokeWidth={1.75}
/>
<strong className={styles.sideEmptyStateTitle}>
{t("agentHost.agentGui.sideEmptyTitle")}
</strong>
<p className={styles.sideEmptyStateDescription}>
{t("agentHost.agentGui.sideEmptyDescription")}
</p>
</div>
}
onLinkAction={onLinkAction}
availableSkills={availableSkills}
workspaceAppIcons={workspaceAppIcons}
labels={conversationFlowLabels}
virtualScrollControllerRef={virtualScrollControllerRef}
/>
{active.error ? (
<p className={styles.sideError}>
{active.error === "content_unsupported"
? t("agentHost.agentGui.sideContentUnsupported")
: t("agentHost.agentGui.sideOperationFailed")}
</p>
) : null}
</ScrollArea>
<div className={styles.sideComposer}>
<AgentComposer {...composerProps} />
</div>
</section>
);
}
export function AgentGUISideConversationPane({
active,
availableSkills,
composerProps,
conversationFlowLabels,
isVisible,
loadingLabel,
workspaceAppIcons,
onClose,
onFocusChange,
onLinkAction
}: AgentGUISideConversationPaneProps): React.JSX.Element {
const { t } = useTranslation();
const [widthPx, setWidthPx] = useState(440);
const resize = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
const handle = event.currentTarget;
const pane = handle.parentElement;
const workbench = pane?.parentElement;
if (!pane || !workbench) return;
const paneRight = pane.getBoundingClientRect().right;
const maxWidth = Math.min(
600,
Math.max(360, workbench.getBoundingClientRect().width - 320)
);
handle.setPointerCapture(event.pointerId);
const onPointerMove = (moveEvent: PointerEvent) => {
setWidthPx(
Math.min(maxWidth, Math.max(360, paneRight - moveEvent.clientX))
);
};
const finish = () => {
handle.removeEventListener("pointermove", onPointerMove);
handle.removeEventListener("pointerup", finish);
handle.removeEventListener("pointercancel", finish);
};
handle.addEventListener("pointermove", onPointerMove);
handle.addEventListener("pointerup", finish);
handle.addEventListener("pointercancel", finish);
}, []);
return (
<div
className={styles.sidePane}
style={{ "--agent-gui-side-pane-width": `${widthPx}px` } as CSSProperties}
>
<div
className={styles.sideResizeHandle}
role="separator"
aria-orientation="vertical"
aria-label={t("agentHost.agentGui.sideResize")}
tabIndex={0}
onPointerDown={resize}
onKeyDown={(event) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
setWidthPx((current) =>
Math.min(
600,
Math.max(360, current + (event.key === "ArrowLeft" ? 24 : -24))
)
);
}}
/>
<header className={styles.sideHeader}>
<button
type="button"
className={styles.sideCloseButton}
aria-label={t("agentHost.agentGui.sideCollapse")}
title={t("agentHost.agentGui.sideCollapse")}
onClick={() => {
void Promise.resolve(onClose()).catch(() => {});
}}
disabled={active.status === "closing"}
>
<PanelIcon aria-hidden="true" className="size-[18px]" />
</button>
</header>
<AgentGUISideConversationSurface
active={active}
availableSkills={availableSkills}
composerProps={composerProps}
conversationFlowLabels={conversationFlowLabels}
isVisible={isVisible}
loadingLabel={loadingLabel}
workspaceAppIcons={workspaceAppIcons}
onFocusChange={onFocusChange}
onLinkAction={onLinkAction}
/>
</div>
);
}