diff --git a/.changeset/fn-7311-chat-starter-prompts.md b/.changeset/fn-7311-chat-starter-prompts.md new file mode 100644 index 0000000000..66149a5eac --- /dev/null +++ b/.changeset/fn-7311-chat-starter-prompts.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add starter prompts to the task-detail Chat empty state. +category: feature +dev: Planner Chat now renders guided empty-state prompt buttons that send ordinary chat messages. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 64ecf08841..0d36514c0d 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1121,6 +1121,8 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig The **Activity** tab is the first task-detail tab and presents a segmented control for **Current**, **Feed**, and **Raw Logs**. Current contains the live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Agent group headers and user message headers show a small muted relative timestamp (for example, “just now”, “1m ago”, or “2h ago”) based on the transcript timestamp, while agent group metadata still includes the entry count. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default and mirrors regular Chat's dense treatment; the summary stays single-line/ellipsis-friendly on desktop and mobile, counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When older task-agent history exists, scrolling to the top or selecting **Load previous messages** prepends earlier transcript entries without moving the message you were reading. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the **Steering comment** composer sends guidance through the same steering path used by comments, including active planning/triage, `in-progress`, and `in-review` sessions, plus live CLI-agent sessions reported by the session bridge; an `in-review` Activity Current message or Comments-tab task comment re-engages an executor unless an open PR blocks moving the task back, and other messages are still saved as queued guidance when no session is currently live. Feed and Raw Logs do not show a steering composer. On a `done` task, the composer switches to **Refinement request** copy; sending starts a refinement task using the typed text as feedback and shows a success toast with the new task ID, while the current task detail modal remains on the completed task. The task-detail Activity Current segment keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint. In the composer, plain **Enter** sends, **Shift+Enter** inserts a newline, and **Cmd/Ctrl+Enter** remains a supported send shortcut. +The adjacent top-level **Chat** tab opens the planner-model conversation for the same task instead of posting steering comments. When no planner-chat history has loaded yet, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review; selecting one sends that prompt as an ordinary chat message through the planner-chat composer/stream path. The starter prompts disappear while history is loading or after conversation history exists, so Activity Current, Feed, Raw Logs, and the steering composer remain separate. + The **Raw Logs** segment is designed for debugging long-running and tool-heavy sessions, while legacy links that requested the former top-level Logs tab land on Activity → Feed: - Full `thinking`, `tool_result`, and `tool_error` payloads are shown without entry-content truncation. diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.css b/packages/dashboard/app/components/TaskPlannerChatTab.css index 1e9c9559c2..8639d23829 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.css +++ b/packages/dashboard/app/components/TaskPlannerChatTab.css @@ -65,22 +65,58 @@ .task-planner-chat-empty { flex-direction: column; - max-width: 36rem; + width: 100%; + max-width: 42rem; + gap: var(--space-lg); } -.task-planner-chat-empty p { +.task-planner-chat-empty-copy { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.task-planner-chat-empty-copy h5, +.task-planner-chat-empty-copy p { margin: 0; } +.task-planner-chat-empty-copy h5 { + color: var(--text); + font-size: var(--font-size-lg); +} + .task-planner-chat-starters { - display: flex; - flex-wrap: wrap; - justify-content: center; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-sm); + width: 100%; } .task-planner-chat-starter { + align-items: flex-start; + justify-content: flex-start; + min-width: 0; + height: auto; white-space: normal; + text-align: left; +} + +.task-planner-chat-starter-label, +.task-planner-chat-starter-description { + display: block; +} + +.task-planner-chat-starter-label { + color: var(--text); + font-weight: 600; +} + +.task-planner-chat-starter-description { + margin-top: var(--space-xs); + color: var(--text-muted); + font-size: var(--font-size-xs); + line-height: var(--line-height-tight); } .task-planner-chat-message { @@ -143,6 +179,15 @@ align-items: stretch; } + .task-planner-chat-starters { + grid-template-columns: 1fr; + } + + .task-planner-chat-send { + justify-content: center; + width: 100%; + } + .task-planner-chat-message { max-width: 100%; } diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index a69df6608f..b58904e14e 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -23,6 +23,55 @@ interface TaskPlannerChatTabProps { type ComposerState = "idle" | "sending"; +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()); } @@ -83,39 +132,73 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add const [draft, setDraft] = useState(""); const [composerState, setComposerState] = useState("idle"); const [loading, setLoading] = useState(false); + const [historyLoaded, setHistoryLoaded] = useState(false); const [error, setError] = useState(null); const streamRef = useRef<{ close: () => void } | null>(null); const transcriptRef = useRef(null); + const loadRequestRef = useRef(0); + const streamRequestRef = useRef(0); + const planningModelProvider = isUsableModel(planningModel) ? planningModel.provider : undefined; + const planningModelId = isUsableModel(planningModel) ? planningModel.modelId : undefined; const modelPayload = useMemo(() => { - return isUsableModel(planningModel) - ? { modelProvider: planningModel.provider, modelId: planningModel.modelId } + return planningModelProvider && planningModelId + ? { modelProvider: planningModelProvider, modelId: planningModelId } : {}; - }, [planningModel]); + }, [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 ensureTaskPlannerChatSession(task.id, modelPayload, projectId); + if (loadRequestRef.current !== requestId) 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 { - setLoading(false); + 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(""); + 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; }; @@ -130,6 +213,10 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add const content = messageContent.trim(); if (!content || composerState === "sending") return; + const streamRequestId = streamRequestRef.current + 1; + streamRequestRef.current = streamRequestId; + const isCurrentStreamRequest = () => streamRequestRef.current === streamRequestId; + setDraft(""); setComposerState("sending"); setError(null); @@ -138,6 +225,7 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add 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)]); @@ -145,11 +233,13 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add 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"); @@ -157,6 +247,7 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add }); }, 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"); @@ -164,6 +255,7 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add }); }, onToolEnd: ({ toolName, isError, result }) => { + if (!isCurrentStreamRequest()) return; const running = [...streamingToolCalls].reverse().find((toolCall) => toolCall.toolName === toolName && toolCall.status === "running"); if (running) { running.status = "completed"; @@ -178,6 +270,7 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add }); }, onDone: (data) => { + if (!isCurrentStreamRequest()) return; setComposerState("idle"); streamRef.current = null; if (data.message) { @@ -186,12 +279,21 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add return sortMessages([...withoutTemporary, data.message!]); }); } else { - void fetchChatMessages(resolvedSessionId, { order: "asc" }, projectId).then(({ messages: refreshed }) => { - setMessages(sortMessages(refreshed)); - }); + void fetchChatMessages(resolvedSessionId, { order: "asc" }, projectId) + .then(({ messages: refreshed }) => { + if (!isCurrentStreamRequest()) return; + setMessages(sortMessages(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) => { + if (!isCurrentStreamRequest()) return; const message = typeof streamError === "string" ? streamError : streamError.summary; setError(message || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond")); setComposerState("idle"); @@ -202,6 +304,7 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add projectId, ); } catch (err) { + if (!isCurrentStreamRequest()) return; const message = getErrorMessage(err) || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond"); setError(message); addToast(message, "error"); @@ -218,16 +321,37 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add }, [sendMessage]); const canSend = draft.trim().length > 0 && composerState !== "sending"; - const starterPrompts = useMemo(() => [ - t("taskDetail.plannerChat.starterStatus", "What is the current state of this task?"), - t("taskDetail.plannerChat.starterNext", "What should happen next?"), - t("taskDetail.plannerChat.starterRisk", "What risks or dependencies should I watch?"), - t("taskDetail.plannerChat.starterSteer", "Help me turn this into clear steering for the executor."), - ], [t]); + const showEmptyState = historyLoaded && !loading && !error && messages.length === 0; + const starterPrompts = useMemo(() => { + const seenLabels = new Set(); + 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]); /* 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:TaskDetailPlannerChat 2026-06-30-23:59: + The empty Chat tab starts with guided task-state prompts that submit ordinary user messages through the same planner-chat stream as the composer. Context enrichment, steering conversion, and structured question 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-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. */ return (
@@ -251,22 +375,29 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add