Preserve sent chat turns when provider failures arrive after the server accepts a stream. - Track whether chat stream errors happen before or after server acceptance. - Reconcile persisted user-message echoes with optimistic bubbles across global chat and planner chat. - Preserve delivered room-chat messages when reply generation or recovery refresh fails. - Cover accepted-error and pre-acceptance rollback behavior with focused dashboard tests. Files changed: .changeset/fn-7368-chat-provider-error.md | 7 ++ packages/dashboard/app/api/legacy.ts | 25 ++++-- .../app/components/TaskPlannerChatTab.tsx | 47 ++++++++++- .../__tests__/TaskPlannerChatTab.test.tsx | 71 ++++++++++++++++ .../dashboard/app/hooks/__tests__/useChat.test.ts | 96 ++++++++++++++++++++++ .../app/hooks/__tests__/useChatRooms.test.ts | 25 ++++++ .../app/hooks/createChatStreamHandlers.ts | 10 +-- packages/dashboard/app/hooks/useChat.ts | 48 ++++++++--- packages/dashboard/app/hooks/useChatRooms.ts | 5 +- 9 files changed, 305 insertions(+), 29 deletions(-) Fusion-Task-Id: FN-7368 Fusion-Task-Lineage: b7935c3f-3604-4ed5-b32e-a6ead536a991 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
769 lines
35 KiB
TypeScript
769 lines
35 KiB
TypeScript
import type { ChatMessage, ResolvedModelSelection, Task, TaskDetail } from "@fusion/core";
|
|
import { getErrorMessage } from "@fusion/core";
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Loader2, Maximize2, Minimize2 } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { ToastType } from "../hooks/useToast";
|
|
import type { ChatMessageInfo, ToolCallInfo } from "../hooks/chatTypes";
|
|
import { ensureTaskPlannerChatSession, fetchChatMessages, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, type ChatStreamErrorMeta } from "../api";
|
|
import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall";
|
|
import { ChatQuestionResponse } from "./ChatQuestionResponse";
|
|
import { ProviderIcon } from "./ProviderIcon";
|
|
import { StandardChatActionButton, StandardChatMessageItem, StandardStreamingMessage, formatModelTag } from "./StandardChatSurface";
|
|
import "./TaskPlannerChatTab.css";
|
|
|
|
interface TaskPlannerChatTabProps {
|
|
task: Task | TaskDetail;
|
|
projectId?: string;
|
|
active: boolean;
|
|
expanded?: boolean;
|
|
onExpandedChange?: (expanded: boolean) => void;
|
|
planningModel: ResolvedModelSelection;
|
|
addToast: (msg: string, type?: ToastType) => void;
|
|
onTaskUpdated?: (task: Task) => void;
|
|
}
|
|
|
|
type ComposerState = "idle" | "sending";
|
|
|
|
type PlannerQuestionRenderState = {
|
|
parsed: ParsedQuestionToolCall;
|
|
answered: boolean;
|
|
submittedAnswer?: string;
|
|
hiddenDuplicate: boolean;
|
|
};
|
|
|
|
interface StarterPromptDefinition {
|
|
id: string;
|
|
labelKey: string;
|
|
labelFallback: string;
|
|
descriptionKey: string;
|
|
descriptionFallback: string;
|
|
messageKey: string;
|
|
messageFallback: string;
|
|
}
|
|
|
|
const TASK_PLANNER_CHAT_STARTER_PROMPTS: StarterPromptDefinition[] = [
|
|
{
|
|
id: "recent-activity",
|
|
labelKey: "taskDetail.plannerChat.starters.recentActivity.label",
|
|
labelFallback: "Summarize recent activity",
|
|
descriptionKey: "taskDetail.plannerChat.starters.recentActivity.description",
|
|
descriptionFallback: "Get a concise recap before reading the full Activity feed.",
|
|
messageKey: "taskDetail.plannerChat.starters.recentActivity.message",
|
|
messageFallback: "Summarize the recent activity for this task and call out anything important I should know.",
|
|
},
|
|
{
|
|
id: "status-blockers",
|
|
labelKey: "taskDetail.plannerChat.starters.statusBlockers.label",
|
|
labelFallback: "Explain status and blockers",
|
|
descriptionKey: "taskDetail.plannerChat.starters.statusBlockers.description",
|
|
descriptionFallback: "Understand where the task stands and what might be blocking it.",
|
|
messageKey: "taskDetail.plannerChat.starters.statusBlockers.message",
|
|
messageFallback: "Explain the current status of this task, including any blockers, risks, or dependencies.",
|
|
},
|
|
{
|
|
id: "next-action",
|
|
labelKey: "taskDetail.plannerChat.starters.nextAction.label",
|
|
labelFallback: "Identify the next best action",
|
|
descriptionKey: "taskDetail.plannerChat.starters.nextAction.description",
|
|
descriptionFallback: "Ask for a practical next step for this task's current state.",
|
|
messageKey: "taskDetail.plannerChat.starters.nextAction.message",
|
|
messageFallback: "What is the next best action for this task, and why?",
|
|
},
|
|
{
|
|
id: "plan-review",
|
|
labelKey: "taskDetail.plannerChat.starters.planReview.label",
|
|
labelFallback: "Review the plan or definition",
|
|
descriptionKey: "taskDetail.plannerChat.starters.planReview.description",
|
|
descriptionFallback: "Check whether the task definition is ready to execute.",
|
|
messageKey: "taskDetail.plannerChat.starters.planReview.message",
|
|
messageFallback: "Review this task's plan or definition and tell me what is clear, missing, or risky.",
|
|
},
|
|
];
|
|
|
|
function isUsableModel(model: ResolvedModelSelection): model is ResolvedModelSelection & { provider: string; modelId: string } {
|
|
return Boolean(model.provider?.trim() && model.modelId?.trim());
|
|
}
|
|
|
|
function sortMessages(messages: ChatMessage[]): ChatMessage[] {
|
|
return [...messages].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
|
}
|
|
|
|
function makeOptimisticUserMessage(sessionId: string, content: string): ChatMessage {
|
|
return {
|
|
id: `optimistic-${Date.now()}`,
|
|
sessionId,
|
|
role: "user",
|
|
content,
|
|
thinkingOutput: null,
|
|
metadata: { optimistic: true },
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function mergePlannerTranscriptWithOptimistic(current: ChatMessage[], refreshed: ChatMessage[]): ChatMessage[] {
|
|
let next = current.filter((message) => message.id !== "streaming-assistant");
|
|
for (const persisted of sortMessages(refreshed)) {
|
|
if (next.some((message) => message.id === persisted.id)) continue;
|
|
if (persisted.role === "user") {
|
|
const optimisticIndex = next.findIndex((candidate) =>
|
|
candidate.role === "user"
|
|
&& candidate.id.startsWith("optimistic-")
|
|
&& candidate.sessionId === persisted.sessionId
|
|
&& candidate.content.trim() === persisted.content.trim(),
|
|
);
|
|
if (optimisticIndex >= 0) {
|
|
next = next.map((candidate, index) => index === optimisticIndex ? persisted : candidate);
|
|
continue;
|
|
}
|
|
}
|
|
next = [...next, persisted];
|
|
}
|
|
return sortMessages(next);
|
|
}
|
|
|
|
function makeStreamingAssistantMessage(sessionId: string, content: string, toolCalls: ToolCallInfo[] = [], thinkingOutput = ""): ChatMessage {
|
|
return {
|
|
id: "streaming-assistant",
|
|
sessionId,
|
|
role: "assistant",
|
|
content,
|
|
thinkingOutput: thinkingOutput || null,
|
|
metadata: { streaming: true, ...(toolCalls.length > 0 ? { toolCalls } : {}) },
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
const TASK_PLANNER_STEERING_TOOL_NAME = "fn_task_planner_add_steering";
|
|
|
|
interface PlannerSteeringResult {
|
|
text: string;
|
|
id?: string;
|
|
createdAt?: string;
|
|
}
|
|
|
|
function readRecord(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === "object" ? value as Record<string, unknown> : null;
|
|
}
|
|
|
|
function extractPlannerSteeringResult(toolCall: ToolCallInfo): PlannerSteeringResult | null {
|
|
if (toolCall.toolName !== TASK_PLANNER_STEERING_TOOL_NAME || toolCall.isError) return null;
|
|
const resultRecord = readRecord(toolCall.result);
|
|
const detailsRecord = readRecord(resultRecord?.details) ?? resultRecord;
|
|
const commentRecord = readRecord(detailsRecord?.steeringComment);
|
|
const text = typeof commentRecord?.text === "string" && commentRecord.text.trim()
|
|
? commentRecord.text.trim()
|
|
: typeof detailsRecord?.text === "string" && detailsRecord.text.trim()
|
|
? detailsRecord.text.trim()
|
|
: typeof toolCall.args?.text === "string" && toolCall.args.text.trim()
|
|
? toolCall.args.text.trim()
|
|
: "";
|
|
if (!text) return null;
|
|
return {
|
|
text,
|
|
...(typeof commentRecord?.id === "string" && commentRecord.id.trim() ? { id: commentRecord.id.trim() } : {}),
|
|
...(typeof commentRecord?.createdAt === "string" && commentRecord.createdAt.trim() ? { createdAt: commentRecord.createdAt.trim() } : {}),
|
|
};
|
|
}
|
|
|
|
function extractPlannerSteeringTextFromResult(result: unknown): string | null {
|
|
const resultRecord = readRecord(result);
|
|
const detailsRecord = readRecord(resultRecord?.details) ?? resultRecord;
|
|
const commentRecord = readRecord(detailsRecord?.steeringComment);
|
|
const text = typeof commentRecord?.text === "string" && commentRecord.text.trim()
|
|
? commentRecord.text.trim()
|
|
: typeof detailsRecord?.text === "string" && detailsRecord.text.trim()
|
|
? detailsRecord.text.trim()
|
|
: "";
|
|
return text || null;
|
|
}
|
|
|
|
function extractToolCalls(message: Pick<ChatMessage, "metadata">): ToolCallInfo[] {
|
|
const rawToolCalls = message.metadata?.toolCalls;
|
|
if (!Array.isArray(rawToolCalls)) return [];
|
|
return rawToolCalls
|
|
.map((toolCall): ToolCallInfo | null => {
|
|
if (!toolCall || typeof toolCall !== "object") return null;
|
|
const record = toolCall as Record<string, unknown>;
|
|
const toolName = typeof record.toolName === "string" ? record.toolName : "";
|
|
if (!toolName) return null;
|
|
const args = record.args;
|
|
return {
|
|
toolName,
|
|
...(args && typeof args === "object" ? { args: args as Record<string, unknown> } : {}),
|
|
isError: Boolean(record.isError),
|
|
result: record.result,
|
|
status: record.status === "running" ? "running" : "completed",
|
|
};
|
|
})
|
|
.filter((toolCall): toolCall is ToolCallInfo => toolCall !== null);
|
|
}
|
|
|
|
function getPlannerQuestionKey(parsed: ParsedQuestionToolCall): string {
|
|
return JSON.stringify(parsed.questions.map((question) => ({
|
|
id: question.id,
|
|
type: question.type,
|
|
question: question.question,
|
|
options: question.options?.map((option) => [option.id, option.label]),
|
|
})));
|
|
}
|
|
|
|
function isQuestionAnswerFor(message: ChatMessage, parsed: ParsedQuestionToolCall): boolean {
|
|
if (message.role !== "user") return false;
|
|
const trimmed = message.content.trim();
|
|
if (!trimmed) return false;
|
|
return parsed.questions.some((question) => trimmed.includes(`> Q: ${question.question}`));
|
|
}
|
|
|
|
function toStandardChatMessage(message: ChatMessage): ChatMessageInfo {
|
|
return {
|
|
id: message.id,
|
|
sessionId: message.sessionId,
|
|
role: message.role,
|
|
content: message.content,
|
|
thinkingOutput: message.thinkingOutput,
|
|
toolCalls: extractToolCalls(message),
|
|
createdAt: message.createdAt,
|
|
};
|
|
}
|
|
|
|
function buildPlannerQuestionRenderStates(messages: readonly ChatMessage[]): Map<string, PlannerQuestionRenderState> {
|
|
const states = new Map<string, PlannerQuestionRenderState>();
|
|
const latestUnansweredByQuestion = new Map<string, string>();
|
|
|
|
messages.forEach((message, messageIndex) => {
|
|
if (message.role !== "assistant") return;
|
|
extractToolCalls(message).forEach((toolCall, toolCallIndex) => {
|
|
const parsed = parseQuestionToolCall(toolCall);
|
|
if (!parsed) return;
|
|
const stateKey = `${message.id}:${toolCallIndex}`;
|
|
const questionKey = getPlannerQuestionKey(parsed);
|
|
const nextUserAnswer = messages.slice(messageIndex + 1).find((candidate) => isQuestionAnswerFor(candidate, parsed));
|
|
const answered = Boolean(nextUserAnswer);
|
|
if (!answered) {
|
|
const previousPendingKey = latestUnansweredByQuestion.get(questionKey);
|
|
if (previousPendingKey) {
|
|
const previous = states.get(previousPendingKey);
|
|
if (previous) {
|
|
states.set(previousPendingKey, { ...previous, hiddenDuplicate: true });
|
|
}
|
|
}
|
|
latestUnansweredByQuestion.set(questionKey, stateKey);
|
|
}
|
|
states.set(stateKey, {
|
|
parsed,
|
|
answered,
|
|
submittedAnswer: nextUserAnswer?.content,
|
|
hiddenDuplicate: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
return states;
|
|
}
|
|
|
|
export function TaskPlannerChatTab({ task, projectId, active, expanded = false, onExpandedChange, planningModel, addToast, onTaskUpdated }: TaskPlannerChatTabProps) {
|
|
const { t } = useTranslation("app");
|
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [draft, setDraft] = useState("");
|
|
const [streamingThinking, setStreamingThinking] = useState("");
|
|
const [composerState, setComposerState] = useState<ComposerState>("idle");
|
|
const composerStateRef = useRef<ComposerState>("idle");
|
|
const [loading, setLoading] = useState(false);
|
|
const [historyLoaded, setHistoryLoaded] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const streamRef = useRef<{ close: () => void } | null>(null);
|
|
const transcriptRef = useRef<HTMLDivElement | null>(null);
|
|
const loadRequestRef = useRef(0);
|
|
const streamRequestRef = useRef(0);
|
|
|
|
const planningModelProvider = isUsableModel(planningModel) ? planningModel.provider : undefined;
|
|
const planningModelId = isUsableModel(planningModel) ? planningModel.modelId : undefined;
|
|
const planningModelLabel = planningModelProvider && planningModelId ? `${planningModelProvider}/${planningModelId}` : "";
|
|
const activeModelTag = formatModelTag(planningModelProvider, planningModelId);
|
|
const modelPayload = useMemo(() => {
|
|
return planningModelProvider && planningModelId
|
|
? { modelProvider: planningModelProvider, modelId: planningModelId }
|
|
: {};
|
|
}, [planningModelId, planningModelProvider]);
|
|
const plannerChatScopeKey = `${task.id}\u0000${projectId ?? ""}\u0000${planningModelProvider ?? ""}\u0000${planningModelId ?? ""}`;
|
|
|
|
const loadSession = useCallback(async () => {
|
|
const requestId = loadRequestRef.current + 1;
|
|
loadRequestRef.current = requestId;
|
|
setLoading(true);
|
|
setHistoryLoaded(false);
|
|
setError(null);
|
|
try {
|
|
const { session } = await fetchTaskPlannerChatSession(task.id, modelPayload, projectId);
|
|
if (loadRequestRef.current !== requestId) return;
|
|
if (!session) {
|
|
setSessionId(null);
|
|
setMessages([]);
|
|
setHistoryLoaded(true);
|
|
return;
|
|
}
|
|
setSessionId(session.id);
|
|
const { messages: loadedMessages } = await fetchChatMessages(session.id, { order: "asc" }, projectId);
|
|
if (loadRequestRef.current !== requestId) return;
|
|
setMessages(sortMessages(loadedMessages));
|
|
setHistoryLoaded(true);
|
|
} catch (err) {
|
|
if (loadRequestRef.current !== requestId) return;
|
|
const message = getErrorMessage(err) || t("taskDetail.plannerChat.loadFailed", "Failed to load planner chat");
|
|
setError(message);
|
|
setHistoryLoaded(false);
|
|
} finally {
|
|
if (loadRequestRef.current === requestId) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
}, [modelPayload, projectId, task.id, t]);
|
|
|
|
useEffect(() => {
|
|
loadRequestRef.current += 1;
|
|
streamRequestRef.current += 1;
|
|
streamRef.current?.close();
|
|
streamRef.current = null;
|
|
setSessionId(null);
|
|
setMessages([]);
|
|
setDraft("");
|
|
composerStateRef.current = "idle";
|
|
setStreamingThinking("");
|
|
setComposerState("idle");
|
|
setLoading(false);
|
|
setHistoryLoaded(false);
|
|
setError(null);
|
|
}, [plannerChatScopeKey]);
|
|
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
void loadSession();
|
|
return () => {
|
|
loadRequestRef.current += 1;
|
|
};
|
|
}, [active, loadSession]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
streamRequestRef.current += 1;
|
|
streamRef.current?.close();
|
|
streamRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!transcriptRef.current) return;
|
|
if (messages.length === 0) {
|
|
transcriptRef.current.scrollTop = 0;
|
|
return;
|
|
}
|
|
transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
|
|
}, [messages, composerState]);
|
|
|
|
const refreshTaskAfterSteering = useCallback(async () => {
|
|
try {
|
|
const refreshedTask = await fetchTaskDetail(task.id, projectId);
|
|
onTaskUpdated?.(refreshedTask);
|
|
addToast(t("taskDetail.plannerChat.steeringAddedToast", "Added as steering comment"), "success");
|
|
} catch (refreshError) {
|
|
const message = getErrorMessage(refreshError) || t("taskDetail.plannerChat.refreshTaskFailed", "Steering was added, but task details could not refresh");
|
|
setError(message);
|
|
addToast(message, "error");
|
|
}
|
|
}, [addToast, onTaskUpdated, projectId, task.id, t]);
|
|
|
|
const sendMessageContent = useCallback(async (messageContent: string) => {
|
|
const content = messageContent.trim();
|
|
if (!content || composerStateRef.current === "sending") return;
|
|
composerStateRef.current = "sending";
|
|
|
|
const streamRequestId = streamRequestRef.current + 1;
|
|
streamRequestRef.current = streamRequestId;
|
|
const isCurrentStreamRequest = () => streamRequestRef.current === streamRequestId;
|
|
|
|
setDraft("");
|
|
setComposerState("sending");
|
|
setError(null);
|
|
|
|
try {
|
|
const { session } = sessionId
|
|
? { session: { id: sessionId } }
|
|
: await ensureTaskPlannerChatSession(task.id, modelPayload, projectId);
|
|
if (!isCurrentStreamRequest()) return;
|
|
const resolvedSessionId = session.id;
|
|
setSessionId(resolvedSessionId);
|
|
setMessages((current) => [...current, makeOptimisticUserMessage(resolvedSessionId, content)]);
|
|
let accumulated = "";
|
|
let accumulatedThinking = "";
|
|
const streamingToolCalls: ToolCallInfo[] = [];
|
|
|
|
streamRef.current?.close();
|
|
if (!isCurrentStreamRequest()) return;
|
|
streamRef.current = streamChatResponse(
|
|
resolvedSessionId,
|
|
content,
|
|
{
|
|
onText: (delta) => {
|
|
if (!isCurrentStreamRequest()) return;
|
|
accumulated += delta;
|
|
setMessages((current) => {
|
|
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
|
return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls, accumulatedThinking)];
|
|
});
|
|
},
|
|
onThinking: (delta) => {
|
|
if (!isCurrentStreamRequest()) return;
|
|
accumulatedThinking += delta;
|
|
setStreamingThinking(accumulatedThinking);
|
|
setMessages((current) => {
|
|
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
|
return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls, accumulatedThinking)];
|
|
});
|
|
},
|
|
onToolStart: ({ toolName, args }) => {
|
|
if (!isCurrentStreamRequest()) return;
|
|
streamingToolCalls.push({ toolName, args, isError: false, status: "running" });
|
|
setMessages((current) => {
|
|
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
|
return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls, accumulatedThinking)];
|
|
});
|
|
},
|
|
onToolEnd: ({ toolName, isError, result }) => {
|
|
if (!isCurrentStreamRequest()) return;
|
|
const running = [...streamingToolCalls].reverse().find((toolCall) => toolCall.toolName === toolName && toolCall.status === "running");
|
|
if (running) {
|
|
running.status = "completed";
|
|
running.isError = isError;
|
|
running.result = result;
|
|
} else {
|
|
streamingToolCalls.push({ toolName, isError, result, status: "completed" });
|
|
}
|
|
const steeringText = toolName === TASK_PLANNER_STEERING_TOOL_NAME && !isError
|
|
? extractPlannerSteeringTextFromResult(result)
|
|
: null;
|
|
if (steeringText) {
|
|
void refreshTaskAfterSteering();
|
|
}
|
|
setMessages((current) => {
|
|
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
|
return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls, accumulatedThinking)];
|
|
});
|
|
},
|
|
onDone: (data) => {
|
|
if (!isCurrentStreamRequest()) return;
|
|
composerStateRef.current = "idle";
|
|
setComposerState("idle");
|
|
setStreamingThinking("");
|
|
streamRef.current = null;
|
|
if (data.message) {
|
|
setMessages((current) => {
|
|
const withoutTemporary = current.filter((message) => message.id !== "streaming-assistant");
|
|
return sortMessages([...withoutTemporary, data.message!]);
|
|
});
|
|
} else {
|
|
void fetchChatMessages(resolvedSessionId, { order: "asc" }, projectId)
|
|
.then(({ messages: refreshed }) => {
|
|
if (!isCurrentStreamRequest()) return;
|
|
setMessages((current) => mergePlannerTranscriptWithOptimistic(current, refreshed));
|
|
})
|
|
.catch((refreshError) => {
|
|
if (!isCurrentStreamRequest()) return;
|
|
const message = getErrorMessage(refreshError) || t("taskDetail.plannerChat.loadFailed", "Failed to load planner chat");
|
|
setError(message);
|
|
addToast(message, "error");
|
|
});
|
|
}
|
|
},
|
|
onError: (streamError, meta?: ChatStreamErrorMeta) => {
|
|
if (!isCurrentStreamRequest()) return;
|
|
const message = typeof streamError === "string" ? streamError : streamError.summary;
|
|
setError(message || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond"));
|
|
composerStateRef.current = "idle";
|
|
setComposerState("idle");
|
|
setStreamingThinking("");
|
|
streamRef.current = null;
|
|
setMessages((current) => {
|
|
const withoutStreaming = current.filter((candidate) => candidate.id !== "streaming-assistant");
|
|
if (meta?.requestAccepted === false) {
|
|
return withoutStreaming.filter((candidate) => !(candidate.role === "user" && candidate.id.startsWith("optimistic-") && candidate.content.trim() === content));
|
|
}
|
|
return withoutStreaming;
|
|
});
|
|
if (meta?.requestAccepted !== false) {
|
|
void fetchChatMessages(resolvedSessionId, { order: "asc" }, projectId)
|
|
.then(({ messages: refreshed }) => {
|
|
if (!isCurrentStreamRequest()) return;
|
|
setMessages((current) => mergePlannerTranscriptWithOptimistic(current, refreshed));
|
|
})
|
|
.catch(() => {
|
|
// Keep the accepted optimistic user turn visible; a later refresh/SSE will reconcile the persisted id.
|
|
});
|
|
}
|
|
},
|
|
},
|
|
undefined,
|
|
projectId,
|
|
{ taskId: task.id },
|
|
);
|
|
} catch (err) {
|
|
if (!isCurrentStreamRequest()) return;
|
|
const message = getErrorMessage(err) || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond");
|
|
setError(message);
|
|
addToast(message, "error");
|
|
composerStateRef.current = "idle";
|
|
setComposerState("idle");
|
|
setStreamingThinking("");
|
|
}
|
|
}, [addToast, modelPayload, projectId, refreshTaskAfterSteering, sessionId, task.id, t]);
|
|
|
|
const sendMessage = useCallback(() => sendMessageContent(draft), [draft, sendMessageContent]);
|
|
|
|
const stopPlannerStreaming = useCallback(() => {
|
|
streamRequestRef.current += 1;
|
|
streamRef.current?.close();
|
|
streamRef.current = null;
|
|
composerStateRef.current = "idle";
|
|
setComposerState("idle");
|
|
setStreamingThinking("");
|
|
setMessages((current) => current.filter((message) => message.id !== "streaming-assistant"));
|
|
}, []);
|
|
|
|
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (event.key !== "Enter" || event.shiftKey) return;
|
|
event.preventDefault();
|
|
void sendMessage();
|
|
}, [sendMessage]);
|
|
|
|
const canSend = draft.trim().length > 0 && composerState !== "sending";
|
|
const showEmptyState = historyLoaded && !loading && !error && messages.length === 0;
|
|
const questionRenderStates = useMemo(() => buildPlannerQuestionRenderStates(messages), [messages]);
|
|
const starterPrompts = useMemo(() => {
|
|
const seenLabels = new Set<string>();
|
|
return TASK_PLANNER_CHAT_STARTER_PROMPTS.flatMap((prompt) => {
|
|
const label = t(prompt.labelKey, prompt.labelFallback).trim();
|
|
const message = t(prompt.messageKey, prompt.messageFallback).trim();
|
|
if (!label || !message) return [];
|
|
const labelKey = label.toLocaleLowerCase();
|
|
if (seenLabels.has(labelKey)) return [];
|
|
seenLabels.add(labelKey);
|
|
return [{
|
|
id: prompt.id,
|
|
label,
|
|
description: t(prompt.descriptionKey, prompt.descriptionFallback).trim(),
|
|
message,
|
|
}];
|
|
});
|
|
}, [t]);
|
|
|
|
const renderPlannerToolCall = useCallback((message: ChatMessage, toolCall: ToolCallInfo, index: number) => {
|
|
const steeringResult = extractPlannerSteeringResult(toolCall);
|
|
if (steeringResult) {
|
|
return (
|
|
<div key={`${toolCall.toolName}-${index}`} className="task-planner-chat-steering-confirmation" data-testid="task-planner-chat-steering-confirmation">
|
|
<strong>{t("taskDetail.plannerChat.steeringAdded", "Added as steering comment")}</strong>
|
|
<p>{steeringResult.text}</p>
|
|
</div>
|
|
);
|
|
}
|
|
const isRunningSteering = toolCall.toolName === TASK_PLANNER_STEERING_TOOL_NAME && toolCall.status === "running";
|
|
if (isRunningSteering) {
|
|
return (
|
|
<div key={`${toolCall.toolName}-${index}`} className="task-planner-chat-steering-confirmation task-planner-chat-steering-confirmation--pending" data-testid="task-planner-chat-steering-pending">
|
|
<strong>{t("taskDetail.plannerChat.steeringAdding", "Adding steering comment…")}</strong>
|
|
</div>
|
|
);
|
|
}
|
|
if (toolCall.toolName === TASK_PLANNER_STEERING_TOOL_NAME && toolCall.isError) {
|
|
return (
|
|
<div key={`${toolCall.toolName}-${index}`} className="task-planner-chat-steering-confirmation task-planner-chat-steering-confirmation--error" role="alert" data-testid="task-planner-chat-steering-error">
|
|
<strong>{t("taskDetail.plannerChat.steeringFailed", "Steering comment was not added")}</strong>
|
|
</div>
|
|
);
|
|
}
|
|
const questionState = questionRenderStates.get(`${message.id}:${index}`);
|
|
if (!questionState) return undefined;
|
|
if (questionState.hiddenDuplicate) return null;
|
|
return (
|
|
<ChatQuestionResponse
|
|
key={`${toolCall.toolName}-${index}`}
|
|
parsed={questionState.parsed}
|
|
answered={questionState.answered}
|
|
submittedAnswer={questionState.submittedAnswer}
|
|
disabled={composerState === "sending" || questionState.answered}
|
|
compact
|
|
onSubmit={(answerText) => void sendMessageContent(answerText)}
|
|
/>
|
|
);
|
|
}, [composerState, questionRenderStates, sendMessageContent, t]);
|
|
|
|
/*
|
|
FNXC:TaskDetailPlannerChat 2026-06-30-23:58:
|
|
Planner Chat is a separate task-detail surface from Activity steering. It can answer from task context, offer starter prompts, ask structured follow-up questions, and convert explicit operator intent into steering through the server-side planner-chat tool instead of posting every chat message as steering by default.
|
|
|
|
FNXC:TaskDetailChat 2026-06-30-23:59:
|
|
When the planner steering tool succeeds, the Chat transcript must show an explicit confirmation and refresh task detail data immediately so Activity/current steering reflects the persisted comment without closing the modal. Clarification tool calls stay as questions and never insert optimistic steering bubbles.
|
|
|
|
FNXC:TaskDetailPlannerChat 2026-06-30-23:59:
|
|
The empty Chat tab starts with guided task-state prompts that submit ordinary user messages through the same task-context-aware planner-chat stream as the composer. Steering conversion and structured question-modal rendering remain owned by later planner-chat subtasks, so starter prompts are only message text plus accessible affordances here.
|
|
|
|
FNXC:TaskDetailPlannerChat 2026-06-30-23:59:
|
|
Session loads are scoped to the current task/project/model and stale responses are ignored so a delayed previous task load cannot attach starter-prompt sends to the wrong planner-chat session.
|
|
|
|
FNXC:TaskDetailPlannerChat 2026-06-30-18:20:
|
|
Opening or switching to the task-detail Chat tab performs lookup-only history loading. Planner-chat rows are lazily created only by explicit user messages (composer sends, starter prompts, or planner-question answers), so unvisited conversations do not clutter global Chat history.
|
|
|
|
FNXC:TaskDetailPlannerChat 2026-06-30-23:59:
|
|
Stream callbacks are guarded by a per-send token because closing an EventSource/stream is not enough to prevent queued text, tool, done, error, or fallback refresh callbacks from mutating the newly selected task's Chat tab.
|
|
|
|
FNXC:TaskDetailPlannerChat 2026-06-30-23:59:
|
|
Planner-generated clarification questions in the task-detail Chat transcript must reuse ChatQuestionResponse instead of bespoke chat text. Submitted answers stay in the planner-chat lane as ordinary follow-up user messages, render the prior question read-only, and duplicate refetched pending tool calls hide older live forms so users never see competing submit affordances.
|
|
|
|
FNXC:TaskDetailPlannerChat 2026-07-01-09:34:
|
|
Planner Chat delegates transcript bubbles, thinking details, tool-call framing, and mobile send/stop gestures to StandardChatSurface. TaskPlannerChatTab keeps lookup-only session loading, task-context sends, starter prompts, and steering confirmations local so reuse does not collapse the lazy ChatView chunk or merge planner chat with Activity.
|
|
|
|
FNXC:TaskDetailPlannerChat 2026-07-01-00:00:
|
|
Provider failures after planner-chat stream acceptance must keep the user's visible turn because the server may have persisted it and included it in model context. Reconcile accepted optimistic rows with refreshed history, but roll back only explicit pre-acceptance failures.
|
|
|
|
FNXC:TaskDetailPlannerChat 2026-06-30-23:58:
|
|
The planner Chat tab owns an in-view expand/collapse button so mobile users can reclaim vertical room while keeping close/back/task identity controls reachable. This state is independent from Activity Live expansion because Activity still represents operational steering/history, not planner-model conversation.
|
|
*/
|
|
return (
|
|
<section className="task-planner-chat" aria-label={t("taskDetail.plannerChat.label", "Planner chat")} data-testid="task-planner-chat-panel">
|
|
{onExpandedChange && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-icon btn-sm task-planner-chat-expand-toggle task-planner-chat-expand-toggle--overlay"
|
|
onClick={() => onExpandedChange(!expanded)}
|
|
aria-label={expanded ? t("taskDetail.plannerChat.collapse", "Collapse planner chat") : t("taskDetail.plannerChat.expand", "Expand planner chat")}
|
|
aria-pressed={expanded}
|
|
aria-expanded={expanded}
|
|
data-testid="task-planner-chat-expand-toggle"
|
|
>
|
|
{expanded ? <Minimize2 aria-hidden="true" /> : <Maximize2 aria-hidden="true" />}
|
|
</button>
|
|
)}
|
|
<div className="task-planner-chat-transcript" ref={transcriptRef} data-testid="task-planner-chat-transcript">
|
|
{error && <div className="task-planner-chat-error" role="alert">{error}</div>}
|
|
{loading ? (
|
|
<div className="task-planner-chat-state" role="status" aria-live="polite">
|
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
|
<span>{t("taskDetail.plannerChat.loading", "Loading planner chat…")}</span>
|
|
</div>
|
|
) : showEmptyState ? (
|
|
<div className="task-planner-chat-empty" data-testid="task-planner-chat-empty">
|
|
{isUsableModel(planningModel) && (
|
|
<span
|
|
className="task-planner-chat-empty-model"
|
|
data-testid="task-planner-chat-model"
|
|
title={planningModelLabel}
|
|
aria-label={planningModelLabel}
|
|
>
|
|
<ProviderIcon provider={planningModel.provider} size="sm" />
|
|
</span>
|
|
)}
|
|
<div className="task-planner-chat-empty-copy">
|
|
<h5>{t("taskDetail.plannerChat.emptyTitle", "Start a task-aware chat")}</h5>
|
|
<p>{t("taskDetail.plannerChat.emptyBody", "Ask planning questions about this task's current status, recent activity, blockers, next steps, or definition. Starter prompts send as normal chat messages.")}</p>
|
|
</div>
|
|
{starterPrompts.length > 0 && (
|
|
<div className="task-planner-chat-starters" aria-label={t("taskDetail.plannerChat.startersLabel", "Planner chat starter prompts")}>
|
|
{starterPrompts.map((prompt) => (
|
|
<button
|
|
key={prompt.id}
|
|
type="button"
|
|
className="btn task-planner-chat-starter"
|
|
data-testid={`task-planner-chat-starter-${prompt.id}`}
|
|
onClick={() => void sendMessageContent(prompt.message)}
|
|
disabled={composerState === "sending"}
|
|
>
|
|
<span className="task-planner-chat-starter-label">{prompt.label}</span>
|
|
{prompt.description && <span className="task-planner-chat-starter-description">{prompt.description}</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{messages.map((message) => {
|
|
if (message.id === "streaming-assistant") {
|
|
const streamingToolCalls = extractToolCalls(message);
|
|
return (
|
|
<StandardStreamingMessage
|
|
key={message.id}
|
|
streamingText={message.content}
|
|
streamingThinking={message.thinkingOutput ?? streamingThinking}
|
|
streamingToolCalls={streamingToolCalls}
|
|
forcePlain={false}
|
|
agentName={t("taskDetail.plannerChat.assistant", "Planner")}
|
|
hideAssistantIdentity={false}
|
|
showAssistantModelTag={Boolean(activeModelTag)}
|
|
activeModelTag={activeModelTag}
|
|
activeModelProvider={planningModelProvider ?? null}
|
|
toolCallRenderer={(toolCall, index) => renderPlannerToolCall(message, toolCall, index)}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<StandardChatMessageItem
|
|
key={message.id}
|
|
message={toStandardChatMessage(message)}
|
|
forcePlain={false}
|
|
agentName={t("taskDetail.plannerChat.assistant", "Planner")}
|
|
hideAssistantIdentity={false}
|
|
showAssistantModelTag={Boolean(activeModelTag)}
|
|
activeModelTag={activeModelTag}
|
|
activeModelProvider={planningModelProvider ?? null}
|
|
activeSessionId={sessionId}
|
|
isAwaitingQuestionAnswer={message.role === "assistant"}
|
|
onQuestionSubmit={(answerText) => void sendMessageContent(answerText)}
|
|
toolCallRenderer={(toolCall, index) => renderPlannerToolCall(message, toolCall, index)}
|
|
/>
|
|
);
|
|
})}
|
|
{composerState === "sending" && !messages.some((message) => message.id === "streaming-assistant") && (
|
|
<StandardStreamingMessage
|
|
streamingText=""
|
|
streamingThinking={streamingThinking}
|
|
streamingToolCalls={[]}
|
|
forcePlain={false}
|
|
agentName={t("taskDetail.plannerChat.assistant", "Planner")}
|
|
hideAssistantIdentity={false}
|
|
showAssistantModelTag={Boolean(activeModelTag)}
|
|
activeModelTag={activeModelTag}
|
|
activeModelProvider={planningModelProvider ?? null}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="task-planner-chat-composer">
|
|
<textarea
|
|
className="input task-planner-chat-input"
|
|
aria-label={t("taskDetail.plannerChat.inputLabel", "Message planner chat")}
|
|
placeholder={t("taskDetail.plannerChat.placeholder", "Ask the planner about this task…")}
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
disabled={composerState === "sending"}
|
|
rows={1}
|
|
/>
|
|
<StandardChatActionButton
|
|
isStreaming={composerState === "sending"}
|
|
canSend={canSend}
|
|
onSend={sendMessage}
|
|
onStop={stopPlannerStreaming}
|
|
classNameSend="btn btn-primary task-planner-chat-send chat-input-send"
|
|
classNameStop="btn btn-primary task-planner-chat-send chat-input-stop"
|
|
sendLabel={t("taskDetail.plannerChat.send", "Send")}
|
|
stopLabel={t("chat.stopGeneration", "Stop generation")}
|
|
showSendText
|
|
/>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|