diff --git a/apps/aevatar-console-web/src/locales/en-US.ts b/apps/aevatar-console-web/src/locales/en-US.ts index b13eb6cebc..96d994b6ab 100644 --- a/apps/aevatar-console-web/src/locales/en-US.ts +++ b/apps/aevatar-console-web/src/locales/en-US.ts @@ -63,6 +63,38 @@ const enUSMessages = { 'pages.chat.index.reviewPlan': 'Review the plan before creating resources.', 'pages.chat.index.scopeValue': 'Scope {scopeId}', 'pages.chat.index.status.completed': 'Completed', + 'pages.chat.index.statusCenter.actionRequired': 'Action required', + 'pages.chat.index.statusCenter.approvalTool': 'Tool: {toolName}', + 'pages.chat.index.statusCenter.completed': 'Completed', + 'pages.chat.index.statusCenter.completedDescription': + 'The run completed. You can continue in chat.', + 'pages.chat.index.statusCenter.completedTextDescription': + 'The run completed. You can continue in chat.', + 'pages.chat.index.statusCenter.completedTitle': 'Workflow created', + 'pages.chat.index.statusCenter.completedWithTargetDescription': + 'Open Studio to review the created workflow.', + 'pages.chat.index.statusCenter.confirmDescription': + 'No resources are created until you confirm.', + 'pages.chat.index.statusCenter.creatingDescription': + 'The current chat run is still working.', + 'pages.chat.index.statusCenter.creatingTitle': 'Creating workflow', + 'pages.chat.index.statusCenter.currentStep': 'Current step: {stepName}', + 'pages.chat.index.statusCenter.error': 'Run stopped', + 'pages.chat.index.statusCenter.errorDescription': + 'This run stopped or failed. You can send a follow-up message to continue.', + 'pages.chat.index.statusCenter.reviewAction': 'Review action', + 'pages.chat.index.statusCenter.reviewBeforeCreate': + 'Review before creating resources', + 'pages.chat.index.statusCenter.runInterventionFallback': + 'This run is waiting for an operator response.', + 'pages.chat.index.statusCenter.runInterventionWaiting': + 'Run input or approval is waiting below.', + 'pages.chat.index.statusCenter.runningTool': 'Running tool: {toolName}', + 'pages.chat.index.statusCenter.stopHelp': + 'Stop only cancels the current chat run.', + 'pages.chat.index.statusCenter.streamingTitle': 'Working on your request', + 'pages.chat.index.statusCenter.toolApprovalWaiting': + 'Tool approval is waiting below.', 'pages.chat.index.status.creating': 'Creating', 'pages.chat.index.status.draft': 'Draft', 'pages.chat.index.status.error': 'Error', diff --git a/apps/aevatar-console-web/src/locales/zh-CN.ts b/apps/aevatar-console-web/src/locales/zh-CN.ts index 4fe3d832b6..9696fadb22 100644 --- a/apps/aevatar-console-web/src/locales/zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/zh-CN.ts @@ -60,6 +60,38 @@ const zhCNMessages = { 'pages.chat.index.reviewPlan': '创建资源前请先确认计划。', 'pages.chat.index.scopeValue': 'Scope {scopeId}', 'pages.chat.index.status.completed': '已完成', + 'pages.chat.index.statusCenter.actionRequired': '需要处理操作', + 'pages.chat.index.statusCenter.approvalTool': '工具:{toolName}', + 'pages.chat.index.statusCenter.completed': '已完成', + 'pages.chat.index.statusCenter.completedDescription': + '本次运行已完成,你可以继续在 Chat 中追问。', + 'pages.chat.index.statusCenter.completedTextDescription': + '本次运行已完成,你可以继续在 Chat 中追问。', + 'pages.chat.index.statusCenter.completedTitle': 'Workflow 已创建', + 'pages.chat.index.statusCenter.completedWithTargetDescription': + '打开 Studio 查看刚创建的 workflow。', + 'pages.chat.index.statusCenter.confirmDescription': + '在你确认之前,不会真正创建任何资源。', + 'pages.chat.index.statusCenter.creatingDescription': + '当前 Chat run 仍在处理中。', + 'pages.chat.index.statusCenter.creatingTitle': '正在创建 workflow', + 'pages.chat.index.statusCenter.currentStep': '当前步骤:{stepName}', + 'pages.chat.index.statusCenter.error': '运行已停止', + 'pages.chat.index.statusCenter.errorDescription': + '这次运行已停止或失败。你可以发送后续消息继续。', + 'pages.chat.index.statusCenter.reviewAction': '查看操作', + 'pages.chat.index.statusCenter.reviewBeforeCreate': + '创建资源前先确认', + 'pages.chat.index.statusCenter.runInterventionFallback': + '当前 run 正在等待操作员响应。', + 'pages.chat.index.statusCenter.runInterventionWaiting': + '当前有输入或审批等待你处理。', + 'pages.chat.index.statusCenter.runningTool': '正在运行工具:{toolName}', + 'pages.chat.index.statusCenter.stopHelp': + '停止只会取消当前这次 chat run。', + 'pages.chat.index.statusCenter.streamingTitle': '正在处理你的请求', + 'pages.chat.index.statusCenter.toolApprovalWaiting': + '当前有工具审批在下方等待处理。', 'pages.chat.index.status.creating': '创建中', 'pages.chat.index.status.draft': '草稿', 'pages.chat.index.status.error': '错误', diff --git a/apps/aevatar-console-web/src/pages/chat/index.test.tsx b/apps/aevatar-console-web/src/pages/chat/index.test.tsx index 71f43d13a1..c49e039f5d 100644 --- a/apps/aevatar-console-web/src/pages/chat/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/chat/index.test.tsx @@ -114,6 +114,15 @@ async function sendPrompt(prompt: string): Promise { fireEvent.click(screen.getByRole("button", { name: "Send" })); } +function seedConversationHistory( + conversations: readonly Record[] +): void { + window.localStorage.setItem( + "aevatar.chat.localHistory.v1:scope-a", + JSON.stringify(conversations) + ); +} + describe("ChatPage MVP", () => { beforeEach(() => { jest.clearAllMocks(); @@ -259,6 +268,148 @@ describe("ChatPage MVP", () => { expect(await screen.findByRole("button", { name: "Open Workflow Studio" })).toBeTruthy(); }); + it("shows a top-level creating summary from runtime step and tool events", async () => { + const stream = createControlledSseResponse(); + (authFetch as jest.Mock) + .mockResolvedValueOnce( + createSseResponse([ + { + runFinished: { + result: { + output: "Plan ready. Please confirm before I create resources.", + }, + }, + }, + ]) + ) + .mockResolvedValueOnce(stream.response); + + renderWithQueryClient(); + await sendPrompt("Create a workflow from chat"); + fireEvent.click(await screen.findByRole("button", { name: "Confirm and create" })); + + stream.enqueue({ + stepStarted: { + stepName: "Create workflow draft", + }, + timestamp: 1, + }); + stream.enqueue({ + toolCallStart: { + toolCallId: "tool-1", + toolName: "create_team_member", + }, + timestamp: 2, + }); + + expect(await screen.findByText("Creating workflow")).toBeTruthy(); + expect(await screen.findByText("Current step: Create workflow draft")).toBeTruthy(); + expect(await screen.findByText("Running tool: create_team_member")).toBeTruthy(); + expect( + await screen.findByText("Stop only cancels the current chat run.") + ).toBeTruthy(); + }); + + it("shows confirmation in the top-level status center for restored conversations", async () => { + seedConversationHistory([ + { + createdAt: "2026-07-10T07:00:00.000Z", + id: "conv-confirm", + messages: [ + { + content: "Plan ready. Please confirm before I create resources.", + id: "assistant-confirm", + role: "assistant", + status: "complete", + timestamp: 1, + }, + ], + scopeId: "scope-a", + status: "needs_confirmation", + title: "Workflow planning", + updatedAt: "2026-07-10T07:00:00.000Z", + }, + ]); + + renderWithQueryClient(); + fireEvent.click(await screen.findByRole("button", { name: "Workflow planning" })); + + expect(await screen.findByText("Review before creating resources")).toBeTruthy(); + expect(await screen.findByRole("button", { name: "Confirm and create" })).toBeTruthy(); + }); + + it("surfaces pending operator action in the top-level status center", async () => { + seedConversationHistory([ + { + createdAt: "2026-07-10T07:00:00.000Z", + id: "conv-action", + messages: [ + { + content: "Waiting for approval.", + id: "assistant-action", + pendingApproval: { + argumentsJson: "{\"scopeId\":\"scope-a\"}", + isDestructive: false, + requestId: "approval-1", + timeoutSeconds: 30, + toolCallId: "tool-approval-1", + toolName: "create_workflow", + }, + role: "assistant", + status: "complete", + timestamp: 1, + }, + ], + scopeId: "scope-a", + status: "creating", + title: "Workflow approval", + updatedAt: "2026-07-10T07:00:00.000Z", + }, + ]); + + renderWithQueryClient(); + fireEvent.click(await screen.findByRole("button", { name: "Workflow approval" })); + + expect(await screen.findByText("Action required")).toBeTruthy(); + expect(await screen.findByText("Tool approval is waiting below.")).toBeTruthy(); + expect(await screen.findByRole("button", { name: "Review action" })).toBeTruthy(); + }); + + it("shows completed next-step guidance in the top-level status center", async () => { + seedConversationHistory([ + { + createdAt: "2026-07-10T07:00:00.000Z", + id: "conv-complete", + messages: [ + { + content: "Created.", + id: "assistant-complete", + role: "assistant", + status: "complete", + timestamp: 1, + }, + ], + scopeId: "scope-a", + status: "completed_with_studio_target", + target: { + memberId: "member-a", + scopeId: "scope-a", + teamId: "team-a", + workflowId: "workflow-a", + }, + title: "Workflow created", + updatedAt: "2026-07-10T07:00:00.000Z", + }, + ]); + + renderWithQueryClient(); + fireEvent.click(await screen.findByRole("button", { name: "Workflow created" })); + + expect(await screen.findByText("Workflow created")).toBeTruthy(); + expect(await screen.findByText("Open Studio to review the created workflow.")).toBeTruthy(); + expect(await screen.findByRole("button", { name: "Open Workflow Studio" })).toBeTruthy(); + }); + it("opens Workflow Studio only when structured identifiers are returned", async () => { (authFetch as jest.Mock).mockResolvedValueOnce( createSseResponse([ diff --git a/apps/aevatar-console-web/src/pages/chat/index.tsx b/apps/aevatar-console-web/src/pages/chat/index.tsx index a163b26e14..08ae942e01 100644 --- a/apps/aevatar-console-web/src/pages/chat/index.tsx +++ b/apps/aevatar-console-web/src/pages/chat/index.tsx @@ -3,7 +3,6 @@ import { EditOutlined, MessageOutlined, PlusOutlined, - SendOutlined, } from "@ant-design/icons"; import { useQuery } from "@tanstack/react-query"; import { @@ -70,6 +69,17 @@ type StudioJump = { label: string; }; +type ChatRunStatusCenter = { + description: string; + detailLines: string[]; + primaryAction?: + | { kind: "confirm"; label: string } + | { kind: "focus-action"; label: string } + | { kind: "open-target"; label: string }; + tone: "default" | "processing" | "success" | "warning" | "error"; + title: string; +}; + function readChatQueryValue( key: string, search = typeof window === "undefined" ? "" : window.location.search @@ -359,6 +369,207 @@ function formatRelativeTime(isoString: string): string { }); } +function findLatestAssistantMessage( + conversation: ConversationState | null | undefined +): ChatMessage | null { + if (!conversation) { + return null; + } + + return ( + [...conversation.messages] + .reverse() + .find((message) => message.role === "assistant") ?? null + ); +} + +function findPendingActionMessage( + conversation: ConversationState | null | undefined +): ChatMessage | null { + if (!conversation) { + return null; + } + + return ( + [...conversation.messages] + .reverse() + .find( + (message) => message.pendingApproval || message.pendingRunIntervention + ) ?? null + ); +} + +function buildChatRunStatusCenter( + conversation: ConversationState | null, + studioJump: StudioJump | null +): ChatRunStatusCenter | null { + if (!conversation || conversation.messages.length === 0) { + return null; + } + + const latestAssistantMessage = findLatestAssistantMessage(conversation); + const latestRunningStep = [...(latestAssistantMessage?.steps ?? [])] + .reverse() + .find((step) => step.status === "running"); + const latestRunningTool = [...(latestAssistantMessage?.toolCalls ?? [])] + .reverse() + .find((toolCall) => toolCall.status === "running"); + const pendingActionMessage = findPendingActionMessage(conversation); + + if (pendingActionMessage?.pendingApproval) { + return { + description: t( + "pages.chat.index.statusCenter.toolApprovalWaiting", + "Tool approval is waiting below." + ), + detailLines: [ + t("pages.chat.index.statusCenter.approvalTool", "Tool: {toolName}", { + toolName: + pendingActionMessage.pendingApproval.toolName || + pendingActionMessage.pendingApproval.toolCallId || + pendingActionMessage.pendingApproval.requestId, + }), + ], + primaryAction: { + kind: "focus-action", + label: t("pages.chat.index.statusCenter.reviewAction", "Review action"), + }, + title: t("pages.chat.index.statusCenter.actionRequired", "Action required"), + tone: "warning", + }; + } + + if (pendingActionMessage?.pendingRunIntervention) { + return { + description: t( + "pages.chat.index.statusCenter.runInterventionWaiting", + "Run input or approval is waiting below." + ), + detailLines: [ + pendingActionMessage.pendingRunIntervention.prompt || + t( + "pages.chat.index.statusCenter.runInterventionFallback", + "This run is waiting for an operator response." + ), + ], + primaryAction: { + kind: "focus-action", + label: t("pages.chat.index.statusCenter.reviewAction", "Review action"), + }, + title: t("pages.chat.index.statusCenter.actionRequired", "Action required"), + tone: "warning", + }; + } + + switch (conversation.status) { + case "needs_confirmation": + return { + description: t( + "pages.chat.index.statusCenter.confirmDescription", + "No resources are created until you confirm." + ), + detailLines: [], + primaryAction: { + kind: "confirm", + label: t("pages.chat.index.confirmAndCreate", "Confirm and create"), + }, + title: t( + "pages.chat.index.statusCenter.reviewBeforeCreate", + "Review before creating resources" + ), + tone: "warning", + }; + case "creating": + case "streaming": { + const detailLines: string[] = []; + if (latestRunningStep?.name) { + detailLines.push( + t("pages.chat.index.statusCenter.currentStep", "Current step: {stepName}", { + stepName: latestRunningStep.name, + }) + ); + } + if (latestRunningTool?.name) { + detailLines.push( + t("pages.chat.index.statusCenter.runningTool", "Running tool: {toolName}", { + toolName: latestRunningTool.name, + }) + ); + } + detailLines.push( + t( + "pages.chat.index.statusCenter.stopHelp", + "Stop only cancels the current chat run." + ) + ); + + return { + description: t( + "pages.chat.index.statusCenter.creatingDescription", + "The current chat run is still working." + ), + detailLines, + title: + conversation.status === "creating" + ? t( + "pages.chat.index.statusCenter.creatingTitle", + "Creating workflow" + ) + : t( + "pages.chat.index.statusCenter.streamingTitle", + "Working on your request" + ), + tone: "processing", + }; + } + case "completed_with_studio_target": + return { + description: studioJump + ? t( + "pages.chat.index.statusCenter.completedWithTargetDescription", + "Open Studio to review the created workflow." + ) + : t( + "pages.chat.index.statusCenter.completedDescription", + "The run completed. You can continue in chat." + ), + detailLines: [], + primaryAction: studioJump + ? { + kind: "open-target", + label: studioJump.label, + } + : undefined, + title: t("pages.chat.index.statusCenter.completedTitle", "Workflow created"), + tone: "success", + }; + case "completed_text": + return { + description: t( + "pages.chat.index.statusCenter.completedTextDescription", + "The run completed. You can continue in chat." + ), + detailLines: [], + title: t("pages.chat.index.statusCenter.completed", "Completed"), + tone: "success", + }; + case "error": + return { + description: t( + "pages.chat.index.statusCenter.errorDescription", + "This run stopped or failed. You can send a follow-up message to continue." + ), + detailLines: latestAssistantMessage?.error + ? [latestAssistantMessage.error] + : [], + title: t("pages.chat.index.statusCenter.error", "Run stopped"), + tone: "error", + }; + default: + return null; + } +} + const ChatPage: React.FC = () => { const { token } = theme.useToken(); const activeConversationRef = useRef(null); @@ -391,6 +602,14 @@ const ChatPage: React.FC = () => { activeConversation?.status === "streaming" || activeConversation?.status === "creating"; const studioJump = resolveStudioJump(activeConversation?.target); + const pendingActionMessage = useMemo( + () => findPendingActionMessage(activeConversation), + [activeConversation] + ); + const statusCenter = useMemo( + () => buildChatRunStatusCenter(activeConversation, studioJump), + [activeConversation, studioJump] + ); const refreshConversations = useCallback(async () => { if (!scopeId) { @@ -807,6 +1026,19 @@ const ChatPage: React.FC = () => { history.push(studioJump.href); }, [studioJump]); + const handleFocusPendingAction = useCallback(() => { + if (!pendingActionMessage || typeof document === "undefined") { + return; + } + + const element = document.getElementById( + `chat-message-${pendingActionMessage.id}` + ); + element?.scrollIntoView({ behavior: "smooth", block: "center" }); + if (element instanceof HTMLElement) { + element.focus(); + } + }, [pendingActionMessage]); const messageCount = activeConversation?.messages.length ?? 0; @@ -1048,14 +1280,101 @@ const ChatPage: React.FC = () => { {formatStatusLabel(activeConversation.status)} ) : null} - {studioJump ? ( - - ) : null} + {statusCenter ? ( +
+
+
+ + {statusCenter.title} + + + {statusCenter.description} + + {statusCenter.detailLines.map((line) => ( + + {line} + + ))} +
+ + {statusCenter.primaryAction ? ( + + ) : null} +
+
+ ) : null} + {!scopeId && !authSessionQuery.isLoading ? ( { }} > {activeConversation?.messages.map((message) => ( - - ))} - {activeConversation?.status === "needs_confirmation" ? ( -
- - - {t( - "pages.chat.index.reviewPlan", - "Review the plan before creating resources." - )} - - - +
+
- ) : null} + ))} )}