Files
tutti/packages/agent/gui/agent-message-center/WorkspaceAgentMessageCenterPanel.tsx
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

744 lines
22 KiB
TypeScript

import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type JSX
} from "react";
import {
Button,
cn,
Drawer,
DrawerContent,
DrawerDescription,
DrawerTitle,
TooltipProvider
} from "@tutti-os/ui-system";
import type { I18nRuntime } from "@tutti-os/ui-i18n-runtime";
import {
AgentGuiI18nProvider,
useTranslation,
type AgentGuiI18nLocale
} from "../i18n/index";
import { AgentVerticalScrollArea } from "../shared/AgentVerticalScrollArea";
import type { WorkspaceLinkAction } from "../actions/workspaceLinkActions";
import {
isInteractiveMessageCenterItem,
selectMessageCenterAttentionDeckItems,
type WorkspaceAgentMessageCenterItem,
type WorkspaceAgentMessageCenterModel
} from "./workspaceAgentMessageCenterModel";
import { WorkspaceAgentMessageCenterAttentionDeck } from "./WorkspaceAgentMessageCenterAttentionDeck";
import { MessageCenterViewMenu } from "./WorkspaceAgentMessageCenterViewControls";
import {
resolveMessageCenterNotificationAction,
WorkspaceAgentMessageCenterStack,
WorkspaceAgentMessageCenterCard,
type WorkspaceAgentMessageCenterCardProps
} from "./WorkspaceAgentMessageCenterCard";
import { MessageCenterGroupHeading } from "./WorkspaceAgentMessageCenterGroupHeading";
import {
buildMessageCenterProviderOptions,
buildMessageCenterStatusOptions,
groupMessageCenterItems,
itemMatchesViewFilters,
messageCenterStackRenderId,
messageCenterStackScrollSyncSegment,
partitionMessageCenterItemsByAgentUser,
statusFilterSummary,
type MessageCenterGroupBy,
type MessageCenterStatusFilter
} from "./workspaceAgentMessageCenterViewModel";
import {
readMessageCenterFilterPreferences,
writeMessageCenterFilterPreferences
} from "./messageCenterFilterPreferences";
export {
buildWorkspaceAgentInteractivePromptLabels,
WorkspaceAgentMessageCenterCard
} from "./WorkspaceAgentMessageCenterCard";
export type { WorkspaceAgentMessageCenterCardProps } from "./WorkspaceAgentMessageCenterCard";
const MESSAGE_CENTER_TOOLTIP_DELAY_MS = 300;
const MESSAGE_CENTER_STACK_EAGER_SUMMARY_COUNT = 8;
export type WorkspaceAgentMessageCenterPanelPresentation =
| "drawer"
| "embedded";
type WorkspaceAgentMessageCenterPromptInput = Parameters<
WorkspaceAgentMessageCenterCardProps["onSubmitPrompt"]
>[0];
export interface WorkspaceAgentMessageCenterPanelProps {
i18n?: I18nRuntime<string> | null;
locale?: AgentGuiI18nLocale;
open: boolean;
model: WorkspaceAgentMessageCenterModel;
highlightedItemId?: string | null;
portalContainer?: HTMLElement | null;
drawerTopInsetPx?: number;
presentation?: WorkspaceAgentMessageCenterPanelPresentation;
onClose: () => void;
onHighlightedItemSettled?: (itemId: string) => void;
onLinkAction?: (action: WorkspaceLinkAction) => void;
onNotificationActioned?: (input: {
action: string;
provider: string;
}) => void;
onOpenChat: (input: { agentSessionId: string; provider: string }) => void;
promptStatus?: (
item: WorkspaceAgentMessageCenterItem
) => "idle" | "responding" | "unknown" | "failed";
onSubmitPrompt: (input: {
action?: string;
agentSessionId: string;
optionId?: string;
payload?: Record<string, unknown>;
promptKind?: string;
requestId: string;
turnId?: string;
}) => Promise<void> | void;
}
export const WorkspaceAgentMessageCenterPanel = memo(
function WorkspaceAgentMessageCenterPanel({
open,
i18n,
locale,
model,
highlightedItemId = null,
portalContainer = null,
drawerTopInsetPx,
presentation = "drawer",
onClose,
onHighlightedItemSettled,
onLinkAction,
onNotificationActioned,
onOpenChat,
promptStatus = () => "idle",
onSubmitPrompt
}: WorkspaceAgentMessageCenterPanelProps): JSX.Element | null {
"use memo";
if (!open) {
return null;
}
return (
<AgentGuiI18nProvider runtime={i18n} locale={locale}>
<WorkspaceAgentMessageCenterPanelContent
open={open}
model={model}
highlightedItemId={highlightedItemId}
portalContainer={portalContainer}
drawerTopInsetPx={drawerTopInsetPx}
presentation={presentation}
onClose={onClose}
onHighlightedItemSettled={onHighlightedItemSettled}
onLinkAction={onLinkAction}
onNotificationActioned={onNotificationActioned}
onOpenChat={onOpenChat}
promptStatus={promptStatus}
onSubmitPrompt={onSubmitPrompt}
/>
</AgentGuiI18nProvider>
);
}
);
function WorkspaceAgentMessageCenterPanelContent({
open,
model,
highlightedItemId = null,
portalContainer = null,
drawerTopInsetPx,
presentation = "drawer",
onClose,
onHighlightedItemSettled,
onLinkAction,
onNotificationActioned,
onOpenChat,
promptStatus = () => "idle",
onSubmitPrompt
}: Omit<
WorkspaceAgentMessageCenterPanelProps,
"i18n" | "locale"
>): JSX.Element | null {
"use memo";
const { t } = useTranslation();
const [initialFilters] = useState(readMessageCenterFilterPreferences);
const [groupBy, setGroupBy] = useState<MessageCenterGroupBy>(
initialFilters.groupBy
);
const [statusFilters, setStatusFilters] =
useState<Set<MessageCenterStatusFilter> | null>(
initialFilters.statusFilters
);
const [providerFilters, setProviderFilters] = useState<Set<string> | null>(
initialFilters.providerFilters
);
useEffect(() => {
writeMessageCenterFilterPreferences({
groupBy,
statusFilters,
providerFilters
});
}, [groupBy, statusFilters, providerFilters]);
const [expandedStackIds, setExpandedStackIds] = useState<Set<string>>(
() => new Set()
);
const itemNodesRef = useRef<Map<string, HTMLElement>>(new Map());
const lastScrolledHighlightedItemIdRef = useRef<string | null>(null);
const lastFilterResetHighlightedItemIdRef = useRef<string | null>(null);
const statusOptions = useMemo(
() => buildMessageCenterStatusOptions(model.counts, t),
[model.counts, t]
);
const providerOptions = useMemo(
() => buildMessageCenterProviderOptions(model.items),
[model.items]
);
const visibleItems = useMemo(
() =>
model.items.filter((item) =>
itemMatchesViewFilters({
item,
providerFilters,
statusFilters
})
),
[model.items, providerFilters, statusFilters]
);
const deckItems = useMemo(
() => selectMessageCenterAttentionDeckItems(visibleItems),
[visibleItems]
);
const listItems = useMemo(
() => visibleItems.filter((item) => !isInteractiveMessageCenterItem(item)),
[visibleItems]
);
const itemGroups = useMemo(
() => groupMessageCenterItems(listItems, groupBy, t),
[groupBy, t, listItems]
);
const itemGroupStacks = useMemo(
() =>
itemGroups.map((group) => ({
...group,
stacks: partitionMessageCenterItemsByAgentUser(group.items)
})),
[itemGroups]
);
const highlightedItem = useMemo(
() =>
highlightedItemId
? (model.items.find((item) => item.id === highlightedItemId) ?? null)
: null,
[highlightedItemId, model.items]
);
const activeStatusSummary = statusFilterSummary(statusFilters, statusOptions);
const scrollSyncKey = useMemo(
() =>
[
groupBy,
activeStatusSummary,
...deckItems.map((item) => `deck:${item.id}`),
...itemGroupStacks.flatMap((group) =>
group.stacks.map((stack) => {
const stackId = messageCenterStackRenderId(group.id, stack.id);
return messageCenterStackScrollSyncSegment({
expanded: expandedStackIds.has(stackId),
groupId: group.id,
stack
});
})
)
].join("|"),
[activeStatusSummary, deckItems, expandedStackIds, groupBy, itemGroupStacks]
);
const hasActiveFilters = statusFilters !== null || providerFilters !== null;
const headerSummary = useMemo(() => {
if (hasActiveFilters) {
return t("agentHost.workspaceAgentMessageCenterSummaryFiltered", {
count: visibleItems.length,
total: model.counts.all
});
}
if (model.counts.waiting > 0) {
return t("agentHost.workspaceAgentMessageCenterSummaryWaiting", {
count: model.counts.all,
waiting: model.counts.waiting
});
}
if (model.counts.completed > 0) {
return t("agentHost.workspaceAgentMessageCenterSummaryCompleted", {
count: model.counts.all,
completed: model.counts.completed
});
}
return t("agentHost.workspaceAgentMessageCenterSummaryCount", {
count: model.counts.all
});
}, [
hasActiveFilters,
model.counts.all,
model.counts.completed,
model.counts.waiting,
t,
visibleItems.length
]);
const submitPrompt = useCallback(
async (
item: WorkspaceAgentMessageCenterItem,
input: {
action?: string;
optionId?: string;
payload?: Record<string, unknown>;
requestId: string;
}
) => {
const notificationAction = resolveMessageCenterNotificationAction(
item,
input
);
if (notificationAction) {
onNotificationActioned?.({
action: notificationAction,
provider: item.provider
});
}
await onSubmitPrompt({
...input,
agentSessionId:
item.pendingInteractionTarget?.agentSessionId ?? item.agentSessionId,
...(item.pendingInteractionTarget
? { turnId: item.pendingInteractionTarget.turnId }
: {}),
promptKind: item.pendingPrompt?.kind
});
},
[onNotificationActioned, onSubmitPrompt]
);
const setItemNode = useCallback(
(itemId: string, node: HTMLElement | null) => {
if (node) {
itemNodesRef.current.set(itemId, node);
} else {
itemNodesRef.current.delete(itemId);
}
},
[]
);
const toggleStatusFilter = useCallback(
(status: MessageCenterStatusFilter) => {
setStatusFilters((current) => {
const next = new Set(
current ?? statusOptions.map((option) => option.value)
);
if (next.has(status)) {
next.delete(status);
} else {
next.add(status);
}
return next.size === statusOptions.length ? null : next;
});
},
[statusOptions]
);
const toggleProviderFilter = useCallback(
(provider: string) => {
setProviderFilters((current) => {
const next = new Set(
current ?? providerOptions.map((option) => option.value)
);
if (next.has(provider)) {
next.delete(provider);
} else {
next.add(provider);
}
return next.size === providerOptions.length ? null : next;
});
},
[providerOptions]
);
const clearFilters = useCallback(() => {
setStatusFilters(null);
setProviderFilters(null);
}, []);
const expandStack = useCallback((groupId: string) => {
setExpandedStackIds((current) => {
if (current.has(groupId)) {
return current;
}
const next = new Set(current);
next.add(groupId);
return next;
});
}, []);
const collapseStack = useCallback((groupId: string) => {
setExpandedStackIds((current) => {
if (!current.has(groupId)) {
return current;
}
const next = new Set(current);
next.delete(groupId);
return next;
});
}, []);
useLayoutEffect(() => {
if (!open || !highlightedItem) {
return;
}
if (lastFilterResetHighlightedItemIdRef.current === highlightedItem.id) {
return;
}
if (
itemMatchesViewFilters({
item: highlightedItem,
providerFilters,
statusFilters
})
) {
return;
}
lastFilterResetHighlightedItemIdRef.current = highlightedItem.id;
setStatusFilters(null);
setProviderFilters(null);
}, [highlightedItem, open, providerFilters, statusFilters]);
useLayoutEffect(() => {
if (!open || !highlightedItemId) {
return;
}
for (const group of itemGroupStacks) {
for (const stack of group.stacks) {
if (
stack.items.length > 1 &&
stack.items.some((item) => item.id === highlightedItemId)
) {
expandStack(messageCenterStackRenderId(group.id, stack.id));
return;
}
}
}
}, [expandStack, highlightedItemId, itemGroupStacks, open]);
useLayoutEffect(() => {
if (!open || !highlightedItemId) {
lastScrolledHighlightedItemIdRef.current = null;
return;
}
if (
highlightedItem &&
!itemMatchesViewFilters({
item: highlightedItem,
providerFilters,
statusFilters
})
) {
return;
}
if (lastScrolledHighlightedItemIdRef.current === highlightedItemId) {
return;
}
const target = itemNodesRef.current.get(highlightedItemId);
if (!target) {
return;
}
lastScrolledHighlightedItemIdRef.current = highlightedItemId;
target.scrollIntoView({ behavior: "smooth", block: "center" });
target.focus({ preventScroll: true });
}, [
expandedStackIds,
highlightedItem,
highlightedItemId,
open,
providerFilters,
statusFilters,
visibleItems
]);
useEffect(() => {
if (!open || !highlightedItemId) {
return undefined;
}
const timeoutId = window.setTimeout(() => {
onHighlightedItemSettled?.(highlightedItemId);
}, 3200);
return () => {
window.clearTimeout(timeoutId);
};
}, [highlightedItemId, onHighlightedItemSettled, open]);
const renderMessageCenterCard = useCallback(
(
item: WorkspaceAgentMessageCenterItem,
options: { stackedIndex?: number } = {}
) => {
const highlighted = item.id === highlightedItemId;
const stackedIndex = options.stackedIndex;
const card = (
<MessageCenterRenderedCard
key={item.agentSessionId}
highlighted={highlighted}
item={item}
isSubmitting={
promptStatus(item) === "responding" ||
promptStatus(item) === "unknown"
}
lazySummary={
stackedIndex !== undefined &&
stackedIndex >= MESSAGE_CENTER_STACK_EAGER_SUMMARY_COUNT
}
registerNode={setItemNode}
onLinkAction={onLinkAction}
onOpenChat={onOpenChat}
onSubmitPrompt={submitPrompt}
/>
);
if (stackedIndex === undefined) {
return card;
}
return (
<div
key={item.agentSessionId}
className="min-w-0 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200 motion-safe:ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:animate-none"
style={{
animationDelay: `${Math.min(stackedIndex * 24, 96)}ms`
}}
>
{card}
</div>
);
},
[
highlightedItemId,
onLinkAction,
onOpenChat,
promptStatus,
setItemNode,
submitPrompt
]
);
const panelContents = (
<TooltipProvider delayDuration={MESSAGE_CENTER_TOOLTIP_DELAY_MS}>
<div className="flex-none border-b border-[var(--border-1)] px-3.5 pt-3 pb-3">
<div className="flex min-w-0 items-center justify-between gap-3">
{presentation === "drawer" ? (
<div className="min-w-0">
<DrawerTitle className="truncate text-[13px] font-semibold leading-5 text-[var(--text-primary)]">
{t("agentHost.workspaceAgentMessageCenterTitle")}
</DrawerTitle>
<DrawerDescription className="truncate text-[11px] leading-4 text-[var(--text-tertiary)]">
{headerSummary}
</DrawerDescription>
</div>
) : (
<p className="min-w-0 truncate text-[11px] leading-4 text-[var(--text-tertiary)]">
{headerSummary}
</p>
)}
<MessageCenterViewMenu
filtersActive={hasActiveFilters}
groupBy={groupBy}
providerFilters={providerFilters}
providerOptions={providerOptions}
statusFilters={statusFilters}
statusOptions={statusOptions}
onClearFilters={clearFilters}
onGroupByChange={setGroupBy}
onProviderToggle={toggleProviderFilter}
onStatusToggle={toggleStatusFilter}
/>
</div>
</div>
<AgentVerticalScrollArea
className="min-h-0 flex-1"
viewportClassName="flex h-full w-full flex-col px-3.5 pt-4 pb-4"
scrollbarClassName="top-4 bottom-4"
syncKey={scrollSyncKey}
>
{deckItems.length > 0 || listItems.length > 0 ? (
<div className="flex w-full min-w-0 flex-col gap-4">
{deckItems.length > 0 ? (
<WorkspaceAgentMessageCenterAttentionDeck
items={deckItems}
highlightedItemId={highlightedItemId}
isPromptSubmitting={(item) =>
promptStatus(item) === "responding" ||
promptStatus(item) === "unknown"
}
registerNode={setItemNode}
onLinkAction={onLinkAction}
onOpenChat={onOpenChat}
onSubmitPrompt={(item, input) => void submitPrompt(item, input)}
/>
) : null}
{itemGroupStacks.map((group) => (
<section
key={group.id}
className="flex min-w-0 flex-col gap-2.5"
aria-label={`${group.label} ${group.items.length}`}
>
<div className="flex min-w-0 items-center justify-between gap-3 px-0.5">
<MessageCenterGroupHeading group={group} />
</div>
{(() => {
return group.stacks.map((stack) => {
const firstItem = stack.items[0];
if (!firstItem) {
return null;
}
if (stack.items.length === 1) {
return renderMessageCenterCard(firstItem);
}
const stackId = messageCenterStackRenderId(
group.id,
stack.id
);
return (
<MessageCenterStack
key={stackId}
expanded={expandedStackIds.has(stackId)}
groupId={stackId}
highlightedItemId={highlightedItemId}
items={stack.items}
renderCard={renderMessageCenterCard}
onCollapse={collapseStack}
onExpand={expandStack}
/>
);
});
})()}
</section>
))}
</div>
) : model.items.length > 0 ? (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2.5 px-6 py-8 text-center text-[13px] text-[var(--text-tertiary)]">
<span>
{t("agentHost.workspaceAgentMessageCenterFilteredEmpty")}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="border border-[var(--line-2)] bg-[var(--background-fronted)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
onClick={clearFilters}
>
{t("agentHost.workspaceAgentMessageCenterClearFilters")}
</Button>
</div>
) : (
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-8 text-center text-[13px] text-[var(--text-tertiary)]">
{t("agentHost.workspaceAgentMessageCenterEmpty")}
</div>
)}
</AgentVerticalScrollArea>
</TooltipProvider>
);
if (presentation === "embedded") {
return (
<section
aria-label={t("agentHost.workspaceAgentMessageCenterTitle")}
className="t-modal nodrag flex h-full min-h-0 w-full flex-col overflow-hidden bg-[var(--background-panel)] text-[var(--text-primary)] [-webkit-app-region:no-drag]"
data-presentation="embedded"
data-testid="workspace-agent-message-center"
>
{panelContents}
</section>
);
}
return (
<Drawer
open={open}
direction="right"
handleOnly
onOpenChange={(nextOpen: boolean) => {
if (!nextOpen) {
onClose();
}
}}
>
<DrawerContent
className={cn(
"t-modal nodrag min-h-0 w-[min(440px,calc(100vw-16px))] max-w-none overflow-hidden rounded-none border-y-0 border-r-0 bg-[var(--background-panel)] text-[var(--text-primary)] shadow-side-panel data-[vaul-drawer-direction=right]:rounded-none",
"[-webkit-app-region:no-drag]"
)}
data-presentation="drawer"
data-testid="workspace-agent-message-center"
portalContainer={portalContainer ?? undefined}
style={
drawerTopInsetPx === undefined ? undefined : { top: drawerTopInsetPx }
}
showOverlay={false}
aria-label={t("agentHost.workspaceAgentMessageCenterTitle")}
>
{panelContents}
</DrawerContent>
</Drawer>
);
}
const MessageCenterCard = WorkspaceAgentMessageCenterCard;
const MessageCenterStack = WorkspaceAgentMessageCenterStack;
const MessageCenterRenderedCard = memo(function MessageCenterRenderedCard({
highlighted,
isSubmitting,
item,
lazySummary,
onLinkAction,
onOpenChat,
onSubmitPrompt,
registerNode
}: {
highlighted: boolean;
isSubmitting: boolean;
item: WorkspaceAgentMessageCenterItem;
lazySummary: boolean;
onLinkAction?: (action: WorkspaceLinkAction) => void;
onOpenChat: (input: { agentSessionId: string; provider: string }) => void;
onSubmitPrompt: (
item: WorkspaceAgentMessageCenterItem,
input: WorkspaceAgentMessageCenterPromptInput
) => void;
registerNode: (itemId: string, node: HTMLElement | null) => void;
}): JSX.Element {
const cardRef = useCallback(
(node: HTMLElement | null) => {
registerNode(item.id, node);
},
[item.id, registerNode]
);
const handleSubmitPrompt = useCallback(
(input: WorkspaceAgentMessageCenterPromptInput) => {
onSubmitPrompt(item, input);
},
[item, onSubmitPrompt]
);
return (
<MessageCenterCard
cardRef={cardRef}
highlighted={highlighted}
item={item}
isSubmitting={isSubmitting}
lazySummary={lazySummary}
onLinkAction={onLinkAction}
onOpenChat={onOpenChat}
onSubmitPrompt={handleSubmitPrompt}
/>
);
});