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

473 lines
15 KiB
TypeScript

import { Fragment, useCallback, type JSX, type ReactNode } from "react";
import { toast } from "@tutti-os/ui-system";
import { AgentPlanCard } from "./AgentPlanCard";
import { AgentCollaborationRow } from "./AgentCollaborationRow";
import { useOptionalAgentHostApi } from "../../../agentActivityHost";
import {
AGENT_EXTERNAL_LINK_ACTION_SOURCE,
type WorkspaceLinkAction
} from "../../../contexts/workspace/presentation/renderer/actions/workspaceLinkActions";
import {
AgentMessageMarkdown,
type AgentMessageMarkdownWorkspaceAppIcon
} from "../../AgentMessageMarkdown";
import { AgentRichTextReadonly } from "../../AgentRichTextReadonly";
import { resolveAgentConversationLinkAction } from "../actions/agentConversationLinkActions";
import { parseMentionItemFromHref } from "../../../agent-gui/agentGuiNode/agentRichText/agentFileMentionExtension";
import { notifyComposerFileMentionBlocked } from "../../../agent-gui/agentGuiNode/composer/resolveComposerFileMentionLinkAction";
import type { AgentGUIProviderSkillOption } from "../../../agent-gui/agentGuiNode/model/agentGuiNodeTypes";
import type {
AgentMessageContentVM,
AgentMessageRowVM
} from "../contracts/agentMessageRowVM";
import type { AgentConversationParticipantPresentation } from "../contracts/agentConversationParticipantPresentation";
import { AgentConversationParticipantHeader } from "./AgentConversationParticipant";
import { AgentToolGroupRow } from "./AgentToolGroupRow";
import {
AgentVisibleErrorMessage,
recoverVisibleErrorFromMessage
} from "./AgentVisibleErrorMessage";
import { AgentThinkingDisclosure } from "./AgentThinkingDisclosure";
import { AgentTuttiModeCheckpointWakeCard } from "./AgentTuttiModeCheckpointWakeCard";
import { AgentTuttiPlanIssueLinkCard } from "./AgentTuttiPlanIssueLinkCard";
import { RawTimelineJsonDisclosure } from "./RawTimelineJsonDisclosure";
import styles from "../../../agent-gui/agentGuiNode/AgentGUIConversation.styles";
import { AgentUserImageGrid } from "./AgentMessageImages";
import { AgentSelectedTextChip } from "./AgentSelectedTextChip";
import {
AgentUserMessageEditor,
type AgentUserMessageEditRetryControl
} from "./AgentUserMessageEditRetry";
import { useAgentUserMessageEditRetry } from "./useAgentUserMessageEditRetry";
import { AgentCopyableMessageGroup } from "./AgentMessageActions";
import { AgentSystemNoticeMessage } from "./AgentSystemNoticeMessage";
const DEFAULT_TOOL_CALLS_LABEL = (count: number): string =>
`${count} tool calls`;
interface AgentMessageBlockProps {
workspaceRoot: string | null;
basePath: string;
row: AgentMessageRowVM;
editRetry?: AgentUserMessageEditRetryControl;
onLinkAction?: (action: WorkspaceLinkAction) => void;
thinkingLabel: string;
toolCallsLabel?: (count: number) => string;
onAuthLogin?: (provider?: string | null) => void;
// Routes a recovered environment-error CTA to the conversation provider.
provider?: string | null;
availableSkills?: readonly AgentGUIProviderSkillOption[];
workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[];
showRawTimelineJson?: boolean;
rawTimelineJsonLabel?: string;
participantPresentation?: AgentConversationParticipantPresentation;
showParticipantHeader?: boolean;
isActiveTurn?: boolean;
footerAction?: ReactNode;
}
export function AgentMessageBlock({
workspaceRoot,
basePath,
row,
editRetry,
onLinkAction,
thinkingLabel,
toolCallsLabel = DEFAULT_TOOL_CALLS_LABEL,
onAuthLogin,
provider,
availableSkills,
workspaceAppIcons,
showRawTimelineJson = false,
rawTimelineJsonLabel = "",
participantPresentation,
showParticipantHeader = true,
isActiveTurn = false,
footerAction
}: AgentMessageBlockProps): JSX.Element {
"use memo";
const agentHostApi = useOptionalAgentHostApi();
const isUser = row.speaker === "user";
const {
canEdit,
editableTargetMessageId,
editPending,
editState,
handleCancelEdit,
handleEditorKeyDown,
handleEditTextChange,
handleStartEdit,
handleSubmitEdit,
isEditing
} = useAgentUserMessageEditRetry({ editRetry, isUser, row });
const handleLinkClick = useCallback(
(href: string): void => {
const action = resolveAgentConversationLinkAction({
workspaceRoot,
basePath,
href,
source: "agent-markdown"
});
if (action) {
onLinkAction?.(action);
return;
}
// Sent transcripts can retain draft-only file hrefs; never fail silently.
const mention = parseMentionItemFromHref({ name: "", href });
if (
mention?.kind === "file" &&
mention.attachmentId &&
!mention.path.trim()
) {
notifyComposerFileMentionBlocked({
reason: "unavailable",
showError: (message) => {
if (agentHostApi?.toast?.error) {
agentHostApi.toast.error(message);
return;
}
toast.error(message);
},
showInfo: (message) => {
if (agentHostApi?.toast?.info) {
agentHostApi.toast.info(message);
return;
}
toast.error(message);
}
});
}
},
[agentHostApi, basePath, onLinkAction, workspaceRoot]
);
const handleExternalLinkClick = useCallback(
(href: string): void => {
const action = resolveAgentConversationLinkAction({
workspaceRoot,
basePath,
href,
source: AGENT_EXTERNAL_LINK_ACTION_SOURCE
});
if (action) {
onLinkAction?.(action);
}
},
[basePath, onLinkAction, workspaceRoot]
);
const handleCopyMessageText = useCallback(
async (text: string): Promise<boolean> => {
if (!text.trim()) {
return false;
}
try {
const hostWriteText = agentHostApi?.clipboard?.writeText;
if (typeof hostWriteText === "function") {
await hostWriteText(text);
return true;
}
if (
typeof navigator !== "undefined" &&
typeof navigator.clipboard?.writeText === "function"
) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
return false;
}
return false;
},
[agentHostApi]
);
const thinkingContent = !isUser
? row.thinking.map((thinking) => (
<AgentThinkingDisclosure
key={thinking.id}
thinking={thinking}
label={thinkingLabel}
onLinkClick={handleLinkClick}
showRawTimelineJson={showRawTimelineJson}
rawTimelineJsonLabel={rawTimelineJsonLabel}
/>
))
: null;
const leadingToolContent =
!isUser && row.leadingToolRows && row.leadingToolRows.length > 0
? row.leadingToolRows.map((toolRow) => (
<AgentToolGroupRow
key={toolRow.id}
row={toolRow}
label={toolCallsLabel}
thinkingLabel={thinkingLabel}
onLinkClick={handleLinkClick}
showRawTimelineJson={showRawTimelineJson}
rawTimelineJsonLabel={rawTimelineJsonLabel}
/>
))
: null;
const messageContent = row.messages.map((message, messageIndex) => {
const messageFooterAction =
messageIndex === row.messages.length - 1 ? footerAction : null;
const isEditTarget = canEdit && message.id === editableTargetMessageId;
const isEditingTarget = isEditing && isEditTarget;
const rawTimelineJson =
showRawTimelineJson &&
rawTimelineJsonLabel &&
(message.sourceTimelineItems?.length ?? 0) > 0 ? (
<RawTimelineJsonDisclosure
items={message.sourceTimelineItems}
label={rawTimelineJsonLabel}
/>
) : null;
// Recover structured errors, including Claude SDK's standalone login notice.
const recoveredError =
!isUser && !message.visibleError
? recoverVisibleErrorFromMessage(message, provider)
: null;
const renderedContent =
isUser &&
message.contentKind === "selected-text" &&
message.selectedText ? (
<AgentSelectedTextChip selectedText={message.selectedText} />
) : isUser && message.contentKind === "image-grid" ? (
<AgentUserImageGrid message={message} />
) : isUser &&
message.contentKind === "tutti-checkpoint-wake" &&
message.checkpointWake ? (
<AgentTuttiModeCheckpointWakeCard
checkpointWake={message.checkpointWake}
fullText={message.body}
/>
) : isUser ? (
<AgentRichTextReadonly
value={message.body}
documentCacheKey={message.id}
className={`workspace-agents-status-panel__detail-user-message ${styles.userMessageBubble}`}
editorClassName="text-[inherit]"
onLinkClick={handleLinkClick}
availableSkills={availableSkills}
workspaceAppIcons={workspaceAppIcons}
/>
) : message.contentKind === "tutti-plan-issue-link" &&
message.planIssueLink ? (
<AgentTuttiPlanIssueLinkCard
planIssueLink={message.planIssueLink}
workspaceRoot={workspaceRoot}
basePath={basePath}
onLinkAction={onLinkAction}
workspaceAppIcons={workspaceAppIcons}
/>
) : message.visibleError ? (
<AgentVisibleErrorMessage
message={message}
onAuthLogin={onAuthLogin}
onExternalLink={handleExternalLinkClick}
/>
) : recoveredError ? (
<AgentVisibleErrorMessage
message={recoveredError}
onAuthLogin={onAuthLogin}
onExternalLink={handleExternalLinkClick}
/>
) : message.systemNotice ? (
<AgentSystemNoticeMessage message={message} />
) : message.contentKind === "collaboration" && message.collaboration ? (
<AgentCollaborationRow
collaboration={message.collaboration}
workspaceRoot={workspaceRoot}
basePath={basePath}
onLinkAction={onLinkAction}
workspaceAppIcons={workspaceAppIcons}
/>
) : message.contentKind === "plan" ? (
<AgentPlanCardMessage
message={message}
workspaceRoot={workspaceRoot}
basePath={basePath}
onLinkAction={onLinkAction}
workspaceAppIcons={workspaceAppIcons}
/>
) : (
<AgentMessageMarkdown
content={message.body}
documentCacheKey={message.id}
className={styles.assistantMarkdown}
onLinkAction={onLinkAction}
workspaceLinkContext={{
workspaceRoot,
basePath,
source: "agent-markdown"
}}
workspaceAppIcons={workspaceAppIcons}
enableImageZoom
streaming={
isActiveTurn ||
message.statusKind === "working" ||
message.statusKind === "waiting"
}
/>
);
const editor =
isEditingTarget && editState && editRetry ? (
<AgentUserMessageEditor
value={editState.draft}
pending={editPending}
labels={editRetry.labels}
onChange={handleEditTextChange}
onCancel={handleCancelEdit}
onSubmit={handleSubmitEdit}
onKeyDown={handleEditorKeyDown}
/>
) : null;
const content =
editor && message.contentKind !== "image-grid" ? (
editor
) : editor ? (
<>
{renderedContent}
{editor}
</>
) : (
renderedContent
);
const editAction =
isEditTarget && !isEditing && editRetry
? {
disabled: editPending,
label: editRetry.labels.edit,
onClick: handleStartEdit
}
: null;
if (rawTimelineJson) {
return (
<AgentCopyableMessageGroup
key={message.id}
copyText={message.copyText ?? null}
editAction={editAction}
occurredAtUnixMs={message.occurredAtUnixMs}
speaker={row.speaker}
onCopyMessageText={handleCopyMessageText}
footerAction={messageFooterAction}
>
{content}
{rawTimelineJson}
</AgentCopyableMessageGroup>
);
}
const copyText = message.copyText ?? null;
if (copyText || editAction || isEditingTarget) {
return (
<AgentCopyableMessageGroup
key={message.id}
copyText={copyText}
editAction={editAction}
occurredAtUnixMs={message.occurredAtUnixMs}
speaker={row.speaker}
onCopyMessageText={handleCopyMessageText}
footerAction={messageFooterAction}
>
{content}
</AgentCopyableMessageGroup>
);
}
if (messageFooterAction) {
return (
<AgentCopyableMessageGroup
key={message.id}
copyText={null}
editAction={null}
occurredAtUnixMs={message.occurredAtUnixMs}
speaker={row.speaker}
onCopyMessageText={handleCopyMessageText}
footerAction={messageFooterAction}
>
{content}
</AgentCopyableMessageGroup>
);
}
return <Fragment key={message.id}>{content}</Fragment>;
});
const enabledParticipantPresentation =
participantPresentation?.enabled === true ? participantPresentation : null;
const showParticipant =
enabledParticipantPresentation !== null &&
showParticipantHeader &&
row.messages.length > 0;
return (
<div
className={isUser ? styles.userMessageFlow : styles.assistantMessageFlow}
data-agent-message-flow-thinking-first={
!isUser && row.thinking.length > 0 ? "true" : undefined
}
data-agent-message-flow-thinking-last={
!isUser && row.thinking.length > 0 && row.messages.length === 0
? "true"
: undefined
}
data-agent-message-flow-participant={showParticipant ? "true" : undefined}
>
{showParticipant ? (
<AgentConversationParticipantHeader
presentation={enabledParticipantPresentation}
speaker={row.speaker}
/>
) : null}
{thinkingContent}
{leadingToolContent}
{showParticipant ? (
<div
className={styles.participantMessageLayout}
data-agent-message-speaker={row.speaker}
>
<div className={styles.participantMessageContent}>
{messageContent}
</div>
</div>
) : (
messageContent
)}
</div>
);
}
// Codex plan-mode proposals render as a framed card (mirrors the codex TUI
// treating the plan item as a distinct artifact rather than chat text).
function AgentPlanCardMessage({
message,
workspaceRoot,
basePath,
onLinkAction,
workspaceAppIcons
}: {
message: AgentMessageContentVM;
workspaceRoot: string | null;
basePath: string;
onLinkAction?: (action: WorkspaceLinkAction) => void;
workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[];
}): JSX.Element {
"use memo";
return (
<AgentPlanCard copyText={message.body}>
<AgentMessageMarkdown
content={message.body}
className={styles.assistantMarkdown}
onLinkAction={onLinkAction}
workspaceLinkContext={{
workspaceRoot,
basePath,
source: "agent-markdown"
}}
workspaceAppIcons={workspaceAppIcons}
enableImageZoom
/>
</AgentPlanCard>
);
}