mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
198 lines
8 KiB
TypeScript
198 lines
8 KiB
TypeScript
import { AgentTrail } from "@/agentMode/ui/AgentTrailView";
|
|
import { AskUserQuestionCard } from "@/agentMode/ui/AskUserQuestionCard";
|
|
import { PlanProposalCard } from "@/agentMode/ui/PlanProposalCard";
|
|
import { ToolPermissionCard } from "@/agentMode/ui/ToolPermissionCard";
|
|
import { BottomLoadingIndicator } from "@/components/chat-components/BottomLoadingIndicator";
|
|
import ChatSingleMessage from "@/components/chat-components/ChatSingleMessage";
|
|
import { USER_SENDER } from "@/constants";
|
|
import { useChatScrolling } from "@/hooks/useChatScrolling";
|
|
import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend";
|
|
import type {
|
|
AgentChatMessage,
|
|
AskUserQuestionPrompt,
|
|
CurrentPlan,
|
|
PermissionPrompt,
|
|
} from "@/agentMode/session/types";
|
|
import type { ChatMessage } from "@/types/message";
|
|
import { App } from "obsidian";
|
|
import React, { memo, useMemo } from "react";
|
|
|
|
interface AgentChatMessagesProps {
|
|
messages: AgentChatMessage[];
|
|
app: App;
|
|
onDelete: (messageId: string) => void;
|
|
currentPlan: CurrentPlan | null;
|
|
pendingToolPermissions: PermissionPrompt[];
|
|
pendingAskUserQuestions: AskUserQuestionPrompt[];
|
|
chatBackend: AgentChatBackend;
|
|
/** True while a turn is in flight. The last assistant message in the
|
|
* visible list is treated as the streaming placeholder. */
|
|
isLoading: boolean;
|
|
}
|
|
|
|
/**
|
|
* Maps an AgentChatMessage to the subset of ChatMessage fields that
|
|
* `ChatSingleMessage` consumes. Lets us reuse the leaf message renderer
|
|
* without coupling Agent Mode types to the legacy `ChatMessage` shape.
|
|
*/
|
|
function toChatMessageView(m: AgentChatMessage): ChatMessage {
|
|
return {
|
|
id: m.id,
|
|
sender: m.sender,
|
|
message: m.message,
|
|
timestamp: m.timestamp,
|
|
isVisible: m.isVisible,
|
|
isErrorMessage: m.isErrorMessage,
|
|
content: m.content,
|
|
context: m.context,
|
|
};
|
|
}
|
|
|
|
const AgentChatMessages = memo(
|
|
({
|
|
messages,
|
|
app,
|
|
onDelete,
|
|
currentPlan,
|
|
pendingToolPermissions,
|
|
pendingAskUserQuestions,
|
|
chatBackend,
|
|
isLoading,
|
|
}: AgentChatMessagesProps) => {
|
|
const visible = useMemo(() => messages.filter((m) => m.isVisible), [messages]);
|
|
const adapted = useMemo(() => visible.map(toChatMessageView), [visible]);
|
|
const { containerMinHeight, scrollContainerCallbackRef, getMessageKey } = useChatScrolling({
|
|
chatHistory: adapted,
|
|
});
|
|
|
|
const showPlanCard = currentPlan != null && currentPlan.decision === "pending";
|
|
const inlinePlanCard = showPlanCard ? (
|
|
<PlanProposalCard plan={currentPlan} app={app} chatBackend={chatBackend} />
|
|
) : null;
|
|
const inlineToolPermissionCards = pendingToolPermissions.map((req) => (
|
|
<ToolPermissionCard
|
|
key={req.toolCall.toolCallId}
|
|
request={req}
|
|
onResolve={chatBackend.resolveToolPermission.bind(chatBackend)}
|
|
/>
|
|
));
|
|
const inlineAskUserQuestionCards = pendingAskUserQuestions.map((req) => (
|
|
<AskUserQuestionCard
|
|
key={req.requestId}
|
|
request={req}
|
|
onResolve={chatBackend.resolveAskUserQuestion.bind(chatBackend)}
|
|
/>
|
|
));
|
|
const hasTailCards =
|
|
showPlanCard || pendingToolPermissions.length > 0 || pendingAskUserQuestions.length > 0;
|
|
|
|
// The last visible assistant message is the streaming placeholder while
|
|
// a turn is in flight — drives the reasoning-block timer/spinner and the
|
|
// persistent "Thinking" loader below it. The bottom loader is suppressed
|
|
// when the streaming message's tail part is a `thought` (the reasoning
|
|
// block already spins) and when the bubble is still entirely empty (the
|
|
// in-place `isStreamingPlaceholder` spinner covers that).
|
|
const { streamingMessageId, showBottomLoader } = useMemo(() => {
|
|
if (!isLoading) return { streamingMessageId: undefined, showBottomLoader: false };
|
|
let streaming: AgentChatMessage | undefined;
|
|
for (let i = visible.length - 1; i >= 0; i--) {
|
|
if (visible[i].sender !== USER_SENDER) {
|
|
streaming = visible[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!streaming) return { streamingMessageId: undefined, showBottomLoader: false };
|
|
const parts = streaming.parts ?? [];
|
|
const last = parts[parts.length - 1];
|
|
const hasParts = parts.length > 0;
|
|
const hasBody = !!streaming.message;
|
|
const showLoader = last?.kind !== "thought" && (hasParts || hasBody);
|
|
return { streamingMessageId: streaming.id, showBottomLoader: showLoader };
|
|
}, [isLoading, visible]);
|
|
|
|
if (visible.length === 0) {
|
|
return (
|
|
<div className="tw-flex tw-size-full tw-flex-col tw-gap-2 tw-overflow-y-auto tw-px-3 tw-pt-2">
|
|
{isLoading && <BottomLoadingIndicator />}
|
|
{inlinePlanCard}
|
|
{inlineToolPermissionCards}
|
|
{inlineAskUserQuestionCards}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="tw-flex tw-h-full tw-flex-1 tw-flex-col tw-overflow-hidden">
|
|
<div
|
|
ref={scrollContainerCallbackRef}
|
|
data-testid="chat-messages"
|
|
className="tw-relative tw-flex tw-w-full tw-flex-1 tw-select-text tw-flex-col tw-items-start tw-justify-start tw-overflow-y-auto tw-scroll-smooth tw-break-words tw-text-[calc(var(--font-text-size)_-_2px)]"
|
|
>
|
|
{visible.map((message, index) => {
|
|
const isLastMessage = index === visible.length - 1;
|
|
// Reserve scroll headroom only when the last message is the
|
|
// assistant AND there's nothing pinned at the tail (plan card or
|
|
// tool-permission card) — those already provide visible content
|
|
// at the bottom of the stream.
|
|
const shouldApplyMinHeight =
|
|
isLastMessage && message.sender !== USER_SENDER && !hasTailCards;
|
|
const adaptedMessage = adapted[index];
|
|
// When an assistant message has structured parts, the trail owns
|
|
// its entire body — `text` parts already cover streamed prose, so
|
|
// an additional `ChatSingleMessage` would duplicate it.
|
|
const isAssistant = message.sender !== USER_SENDER;
|
|
const hasParts = (message.parts?.length ?? 0) > 0;
|
|
const renderTrail = isAssistant && hasParts;
|
|
// The streaming placeholder (empty body, no parts) renders as a
|
|
// thinking spinner in-place, so the user sees progress the moment
|
|
// they hit send rather than an empty assistant bubble.
|
|
const isStreamingPlaceholder =
|
|
isAssistant && message.id === streamingMessageId && !hasParts && !message.message;
|
|
|
|
return (
|
|
<div
|
|
key={getMessageKey(adaptedMessage, index)}
|
|
data-message-key={getMessageKey(adaptedMessage, index)}
|
|
className="tw-w-full"
|
|
style={{
|
|
minHeight: shouldApplyMinHeight ? `${containerMinHeight}px` : "auto",
|
|
}}
|
|
>
|
|
{isStreamingPlaceholder ? (
|
|
<div className="tw-px-3 tw-pt-2">
|
|
<BottomLoadingIndicator />
|
|
</div>
|
|
) : renderTrail ? (
|
|
<div className="tw-px-3 tw-pt-2">
|
|
<AgentTrail
|
|
parts={message.parts!}
|
|
isStreaming={message.id === streamingMessageId}
|
|
showThinkingTail={message.id === streamingMessageId && showBottomLoader}
|
|
app={app}
|
|
turnStopReason={message.turnStopReason}
|
|
turnDurationMs={message.turnDurationMs}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<ChatSingleMessage
|
|
message={adaptedMessage}
|
|
app={app}
|
|
isStreaming={false}
|
|
onDelete={() => onDelete(message.id)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{inlinePlanCard}
|
|
{inlineToolPermissionCards}
|
|
{inlineAskUserQuestionCards}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
AgentChatMessages.displayName = "AgentChatMessages";
|
|
|
|
export default AgentChatMessages;
|