diff --git a/browser/ai_chat/ai_chat_conversation_task_browsertest.cc b/browser/ai_chat/ai_chat_conversation_task_browsertest.cc index e6f41992b2b..0223fcc40c0 100644 --- a/browser/ai_chat/ai_chat_conversation_task_browsertest.cc +++ b/browser/ai_chat/ai_chat_conversation_task_browsertest.cc @@ -18,6 +18,7 @@ #include "base/test/scoped_feature_list.h" #include "base/test/test_future.h" #include "brave/browser/ai_chat/ai_chat_service_factory.h" +#include "brave/browser/ui/webui/ai_chat/ai_chat_untrusted_conversation_ui.h" #include "brave/components/ai_chat/core/browser/ai_chat_service.h" #include "brave/components/ai_chat/core/browser/conversation_handler.h" #include "brave/components/ai_chat/core/browser/engine/mock_engine_consumer.h" @@ -35,6 +36,7 @@ #include "chrome/browser/ui/browser_navigator_params.h" #include "chrome/test/base/in_process_browser_test.h" #include "chrome/test/base/ui_test_utils.h" +#include "content/public/browser/render_frame_host.h" #include "content/public/browser/web_contents.h" #include "content/public/test/browser_test.h" #include "content/public/test/browser_test_utils.h" @@ -160,12 +162,43 @@ class AIChatConversationTaskBrowserTest : public InProcessBrowserTest { conversation_rfh_ = ui_test_utils::NavigateToURLWithDisposition( agent_browser_window_, url, WindowOpenDisposition::NEW_FOREGROUND_TAB, ui_test_utils::BROWSER_TEST_WAIT_FOR_LOAD_STOP); + // Wait for child frame to exist + VerifyElementState("conversation-entries-iframe"); + EXPECT_TRUE(base::test::RunUntil( + [&] { return GetConversationEntriesFrame() != nullptr; })); + } + + content::RenderFrameHost* GetConversationEntriesFrame() { + content::RenderFrameHost* result = nullptr; + conversation_rfh_->ForEachRenderFrameHost( + [&](content::RenderFrameHost* frame) { + if (frame->GetWebUI() && + frame->GetWebUI() + ->GetController() + ->GetAs()) { + result = frame; + return; + } + }); + return result; + } + + bool VerifyConversationFrameElementState( + const std::string& test_id, + bool expect_exist = true, + base::Location location = base::Location::Current()) { + return VerifyElementState(test_id, expect_exist, + GetConversationEntriesFrame(), location); } // Helper to check if an element with a specific data-testid exists bool VerifyElementState(const std::string& test_id, bool expect_exist = true, + content::RenderFrameHost* frame = nullptr, base::Location location = base::Location::Current()) { + if (!frame) { + frame = conversation_rfh_; + } SCOPED_TRACE(testing::Message() << "VerifyElementState: '" << test_id << "' called from " << location.file_name() << ":" << location.line_number()); @@ -198,7 +231,7 @@ class AIChatConversationTaskBrowserTest : public InProcessBrowserTest { )"; auto result = content::EvalJs( - conversation_rfh_, + frame, content::JsReplace(kWaitForAIChatRenderScript, test_id, !expect_exist)); return result.ExtractBool(); } @@ -245,25 +278,30 @@ class AIChatConversationTaskBrowserTest : public InProcessBrowserTest { base::OnceClosure SetupMockGenerateAssistantResponse( EngineConsumer::GenerationDataCallback* out_data_callback, EngineConsumer::GenerationCompletedCallback* out_completed_callback, + testing::Sequence* sequence = nullptr, base::Location location = base::Location::Current()) { SCOPED_TRACE(testing::Message() << location.ToString()); auto run_loop = std::make_unique(); auto on_generate_called = run_loop->QuitClosure(); - EXPECT_CALL(*mock_engine_, - GenerateAssistantResponse(_, _, _, _, _, _, _, _, _)) - .Description(base::StrCat( - {"GenerateAssistantResponse mocked from ", location.ToString()})) - .WillOnce([out_data_callback, out_completed_callback, - on_called = std::move(on_generate_called)]( - PageContentsMap page_contents, - const EngineConsumer::ConversationHistory& history, - const std::string& selected_language, bool is_temporary, - const std::vector>& provided_tools, - std::optional preferred_tool_name, - mojom::ConversationCapability capability, - EngineConsumer::GenerationDataCallback data_cb, - EngineConsumer::GenerationCompletedCallback - complete_cb) mutable { + auto& expect = + EXPECT_CALL(*mock_engine_, + GenerateAssistantResponse(_, _, _, _, _, _, _, _, _)) + .Description(base::StrCat({"GenerateAssistantResponse mocked from ", + location.ToString()})); + if (sequence) { + expect.InSequence(*sequence); + } + expect.WillOnce( + [out_data_callback, out_completed_callback, + on_called = std::move(on_generate_called)]( + PageContentsMap page_contents, + const EngineConsumer::ConversationHistory& history, + const std::string& selected_language, bool is_temporary, + const std::vector>& provided_tools, + std::optional preferred_tool_name, + mojom::ConversationCapability capability, + EngineConsumer::GenerationDataCallback data_cb, + EngineConsumer::GenerationCompletedCallback complete_cb) mutable { *out_data_callback = std::move(data_cb); *out_completed_callback = std::move(complete_cb); std::move(on_called).Run(); @@ -283,6 +321,12 @@ class AIChatConversationTaskBrowserTest : public InProcessBrowserTest { nullptr); } + mojom::ToolUseEventPtr CreateToolUseEvent(const std::string& tool_name, + const std::string& tool_id) { + return mojom::ToolUseEvent::New(tool_name, tool_id, "{}", std::nullopt, + nullptr); + } + raw_ptr agent_profile_ = nullptr; raw_ptr conversation_rfh_ = nullptr; raw_ptr agent_browser_window_ = nullptr; @@ -557,6 +601,166 @@ IN_PROC_BROWSER_TEST_F(AIChatConversationTaskBrowserTest, TaskStopAction) { } } +IN_PROC_BROWSER_TEST_F(AIChatConversationTaskBrowserTest, TaskUI) { + // A task UI shows when there are 2 tool segments of a tool loop, i.e. the AI + // responds to a tool use result with another tool use request. + CreateConversationWithMockEngine(); + std::string uuid = conversation_handler_->get_conversation_uuid(); + + NavigateToConversationUI(uuid); + + // Inject our own Tool so that we can handle the tool execution and pause + auto* mock_tool = static_cast*>( + content_agent_tool_provider_->AddToolForTesting( + std::make_unique>("mock_tool", "Mock tool"))); + + testing::Sequence tool_call_seq; + base::OnceClosure tool_execute; + // Submit first message + { + EngineConsumer::GenerationDataCallback data_callback; + EngineConsumer::GenerationCompletedCallback completed_callback; + auto wait_for_generate = SetupMockGenerateAssistantResponse( + &data_callback, &completed_callback, &tool_call_seq); + conversation_handler_->SubmitHumanConversationEntry( + "Navigate to example.com", std::nullopt); + std::move(wait_for_generate).Run(); + // Send first message response + // Simulate tool use event + data_callback.Run(EngineConsumer::GenerationResultData( + mojom::ConversationEntryEvent::NewToolUseEvent( + CreateToolUseEvent("mock_tool", "tool_id_1")), + std::nullopt)); + + EXPECT_CALL(*mock_tool, UseTool) + .InSequence(tool_call_seq) + .WillOnce(testing::WithArg<1>([&](Tool::UseToolCallback callback) { + // Wait unt the next round is setup to call the callback + tool_execute = base::BindOnce( + [](Tool::UseToolCallback callback) { + std::move(callback).Run( + CreateContentBlocksForText("1st tool result")); + }, + std::move(callback)); + })); + + // Complete first message response + std::move(completed_callback) + .Run(base::ok( + EngineConsumer::GenerationResultData(nullptr, std::nullopt))); + } + + // No task UI should be shown with only one tool segment in the loop + EXPECT_FALSE(VerifyConversationFrameElementState("assistant-task", false)); + // Handle the tool execution response with another tool use request. + { + EngineConsumer::GenerationDataCallback data_callback; + EngineConsumer::GenerationCompletedCallback completed_callback; + auto wait_for_generate = SetupMockGenerateAssistantResponse( + &data_callback, &completed_callback, &tool_call_seq); + std::move(tool_execute).Run(); + std::move(wait_for_generate).Run(); + + data_callback.Run(EngineConsumer::GenerationResultData( + mojom::ConversationEntryEvent::NewCompletionEvent( + mojom::CompletionEvent::New("Hmm, I want a different thing")), + std::nullopt)); + data_callback.Run(EngineConsumer::GenerationResultData( + mojom::ConversationEntryEvent::NewToolUseEvent( + CreateToolUseEvent("mock_tool", "tool_id_2")), + std::nullopt)); + + // When the tool is being executed, we can verify the UI state + EXPECT_CALL(*mock_tool, UseTool) + .InSequence(tool_call_seq) + .WillOnce(testing::WithArg<1>([&](Tool::UseToolCallback callback) { + EXPECT_TRUE(VerifyConversationFrameElementState("assistant-task")); + EXPECT_FALSE(VerifyConversationFrameElementState( + "tool-event-thinking", false)); + tool_execute = base::BindOnce( + [](Tool::UseToolCallback callback) { + std::move(callback).Run( + CreateContentBlocksForText("2nd tool result")); + }, + std::move(callback)); + })); + + // Complete successful response + std::move(completed_callback) + .Run(base::ok( + EngineConsumer::GenerationResultData(nullptr, std::nullopt))); + } + + // Handle the second tool execution response with pausing and verify UI label. + { + EngineConsumer::GenerationDataCallback data_callback; + EngineConsumer::GenerationCompletedCallback completed_callback; + auto wait_for_generate = SetupMockGenerateAssistantResponse( + &data_callback, &completed_callback, &tool_call_seq); + + // Finish executing the tool + std::move(tool_execute).Run(); + + std::move(wait_for_generate).Run(); + + // Now we should be thinking + EXPECT_TRUE(VerifyConversationFrameElementState("tool-event-thinking")); + + // Shouldn't call the tool again because we are pausing. + EXPECT_CALL(*mock_tool, UseTool).Times(0).InSequence(tool_call_seq); + + // Pause the task + EXPECT_TRUE(ClickElement("pause-task-button")); + + data_callback.Run(EngineConsumer::GenerationResultData( + mojom::ConversationEntryEvent::NewCompletionEvent( + mojom::CompletionEvent::New("Hmm, I want a different thing")), + std::nullopt)); + data_callback.Run(EngineConsumer::GenerationResultData( + mojom::ConversationEntryEvent::NewToolUseEvent( + CreateToolUseEvent("mock_tool", "tool_id_3")), + std::nullopt)); + // Complete successful response + std::move(completed_callback) + .Run(base::ok( + EngineConsumer::GenerationResultData(nullptr, std::nullopt))); + } + + // The task should have a "paused" label + EXPECT_TRUE( + VerifyConversationFrameElementState("assistant-task-paused-label")); + + // When we submit a new message, the task is no longer active. It should still + // exist but should not have its "paused" label. + { + EngineConsumer::GenerationDataCallback data_callback; + EngineConsumer::GenerationCompletedCallback completed_callback; + auto wait_for_generate = SetupMockGenerateAssistantResponse( + &data_callback, &completed_callback, &tool_call_seq); + conversation_handler_->SubmitHumanConversationEntry( + "Actually do something different", std::nullopt); + + EXPECT_TRUE(base::test::RunUntil([this]() { + return GetConversationState()->tool_use_task_state == + mojom::TaskState::kNone; + })); + + std::move(wait_for_generate).Run(); + // Simple response + data_callback.Run(EngineConsumer::GenerationResultData( + mojom::ConversationEntryEvent::NewCompletionEvent( + mojom::CompletionEvent::New("ok")), + std::nullopt)); + // Complete first message response + std::move(completed_callback) + .Run(base::ok( + EngineConsumer::GenerationResultData(nullptr, std::nullopt))); + } + EXPECT_TRUE(VerifyConversationFrameElementState("assistant-task")); + EXPECT_FALSE(VerifyConversationFrameElementState( + "assistant-task-paused-label", false)); +} + #endif // BUILDFLAG(ENABLE_BRAVE_AI_CHAT_AGENT_PROFILE) } // namespace ai_chat diff --git a/browser/ai_chat/content_agent_tool_provider.h b/browser/ai_chat/content_agent_tool_provider.h index a31a6a51123..73f50c6e736 100644 --- a/browser/ai_chat/content_agent_tool_provider.h +++ b/browser/ai_chat/content_agent_tool_provider.h @@ -53,6 +53,11 @@ class ContentAgentToolProvider : public ToolProvider, return task_tab_handle_; } + Tool* AddToolForTesting(std::unique_ptr tool) { + tools_.push_back(std::move(tool)); + return tools_.back().get(); + } + private: friend class BrowserToolsTest; friend class ContentAgentToolProviderTest; diff --git a/components/ai_chat/core/browser/conversation_handler.cc b/components/ai_chat/core/browser/conversation_handler.cc index edbb9f3628b..964022ce500 100644 --- a/components/ai_chat/core/browser/conversation_handler.cc +++ b/components/ai_chat/core/browser/conversation_handler.cc @@ -1925,8 +1925,9 @@ ConversationHandler::GetStateForConversationEntries() { mojom::ConversationEntriesStatePtr entries_state = mojom::ConversationEntriesState::New(); - entries_state->is_generating = - IsRequestInProgress() || is_tool_use_in_progress_; + entries_state->is_generating = IsRequestInProgress(); + entries_state->is_tool_executing = is_tool_use_in_progress_; + entries_state->tool_use_task_state = tool_use_task_state_; entries_state->is_leo_model = is_leo_model; entries_state->all_models = std::move(models_copy); entries_state->current_model_key = model.key; @@ -1997,8 +1998,7 @@ void ConversationHandler::OnSuggestedQuestionsChanged() { void ConversationHandler::OnAPIRequestInProgressChanged() { OnStateForConversationEntriesChanged(); for (auto& client : conversation_ui_handlers_) { - client->OnAPIRequestInProgress(is_request_in_progress_ || - is_tool_use_in_progress_); + client->OnAPIRequestInProgress(is_request_in_progress_); } for (auto& observer : observers_) { observer.OnRequestInProgressChanged(this, is_request_in_progress_); @@ -2006,6 +2006,7 @@ void ConversationHandler::OnAPIRequestInProgressChanged() { } void ConversationHandler::OnToolUseTaskStateChanged() { + OnStateForConversationEntriesChanged(); for (auto& client : conversation_ui_handlers_) { client->OnTaskStateChanged(tool_use_task_state_); } diff --git a/components/ai_chat/core/common/mojom/common.mojom b/components/ai_chat/core/common/mojom/common.mojom index 11a73397644..5289ce5fa8b 100644 --- a/components/ai_chat/core/common/mojom/common.mojom +++ b/components/ai_chat/core/common/mojom/common.mojom @@ -365,6 +365,10 @@ struct Model { struct ConversationEntriesState { // Whether an answer generation is in progress bool is_generating; + // Whether the most recent tool use request is being executed + bool is_tool_executing; + // State of any active tool loop task + TaskState tool_use_task_state; // Whether the current model is a built-in Leo model bool is_leo_model; array all_models; diff --git a/components/ai_chat/resources/page/chat_ui.tsx b/components/ai_chat/resources/page/chat_ui.tsx index cf1be1f4b19..eedf84e5fd0 100644 --- a/components/ai_chat/resources/page/chat_ui.tsx +++ b/components/ai_chat/resources/page/chat_ui.tsx @@ -162,6 +162,7 @@ function ConversationEntries(props: ConversationEntriesProps) { + conversationContext.conversationUuid } ref={iframeRef} + data-testid='conversation-entries-iframe' onLoad={() => setHasLoaded(true)} /> ) diff --git a/components/ai_chat/resources/page/stories/components_panel.tsx b/components/ai_chat/resources/page/stories/components_panel.tsx index cbaf1ef3d9b..b857efe8379 100644 --- a/components/ai_chat/resources/page/stories/components_panel.tsx +++ b/components/ai_chat/resources/page/stories/components_panel.tsx @@ -992,6 +992,7 @@ type CustomArgs = { totalTokens: number trimmedTokens: number isGenerating: boolean + isToolExecuting: boolean attachmentsDialog: 'tabs' | 'bookmarks' | 'history' | null isNewConversation: boolean generatedUrlToBeOpened: Url | undefined @@ -1041,6 +1042,7 @@ const args: CustomArgs = { totalTokens: 0, trimmedTokens: 0, isGenerating: false, + isToolExecuting: false, attachmentsDialog: null, isNewConversation: false, generatedUrlToBeOpened: undefined, @@ -1395,6 +1397,8 @@ function StoryContext( conversationHistory: conversationContext.conversationHistory, conversationCapability: Mojom.ConversationCapability.CONTENT_AGENT, isGenerating: conversationContext.isGenerating, + isToolExecuting: args.isToolExecuting, + toolUseTaskState: conversationContext.toolUseTaskState, isLeoModel: conversationContext.isCurrentModelLeo, contentUsedPercentage: options.args.shouldShowLongPageWarning ? 48 : 100, visualContentUsedPercentage: options.args.shouldShowLongVisualContentWarning @@ -1546,6 +1550,7 @@ export const _ToolUse = { ))} diff --git a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/index.tsx b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/index.tsx index fdb7ff9b8c9..79990e8197b 100644 --- a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/index.tsx +++ b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/index.tsx @@ -85,6 +85,7 @@ function AssistantEvent( }, ) { const { allowedLinks, event, isEntryInProgress, isLeoModel } = props + const context = useUntrustedConversationContext() if (event.completionEvent) { const numberedLinks = @@ -139,6 +140,7 @@ function AssistantEvent( ) } diff --git a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/tool_event.module.scss b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/tool_event.module.scss index d07e955cfc5..88638c56870 100644 --- a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/tool_event.module.scss +++ b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/tool_event.module.scss @@ -29,7 +29,7 @@ } } - &.isActive .toolLabel { + &.isExecuting .toolLabel { background: linear-gradient( 90deg, #434fcf 0%, diff --git a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/tool_event.tsx b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/tool_event.tsx index 72ac68d9604..d5c4d36db0d 100644 --- a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/tool_event.tsx +++ b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/tool_event.tsx @@ -5,6 +5,7 @@ import * as React from 'react' import classnames from '$web-common/classnames' +import { getLocale } from '$web-common/locale' import * as Mojom from '../../../common/mojom' import ToolPermissionChallenge from '../tool_permission_challenge/tool_permission_challenge' import { getToolLabel } from './get_tool_label' @@ -20,6 +21,12 @@ interface Props { * what to do with partial input. */ isEntryActive: boolean + + /** + * Whether the tool use request is currently being executed. This is usually + * a short-lived state. + */ + isExecuting: boolean } /** @@ -158,11 +165,16 @@ export default function ToolEvent(props: Props) { const isExpandable = content.toolLabel && content.expandedContent + // Show as executing if this is an active entry, we are currently + // executing *a* tool, and this tool has no output yet. + const isExecuting = + props.isEntryActive && props.isExecuting && !props.toolUseEvent.output + return (
{isExpandable && ( @@ -205,3 +217,16 @@ function ToolContentExpandable(props: Props & ToolUseContent) { ) } + +export function ToolEventThinking() { + return ( +
+
+ {getLocale(S.CHAT_UI_TOOL_LABEL_THINKING)} +
+
+ ) +} diff --git a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_task/assistant_task.module.scss b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_task/assistant_task.module.scss index 97d9b1c2318..84ccc711c6a 100644 --- a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_task/assistant_task.module.scss +++ b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_task/assistant_task.module.scss @@ -38,6 +38,10 @@ } } +.taskStateLabel { + margin-top: var(--leo-spacing-m); +} + .taskStep { --task-icon-size: 22px; --half-task-icon-size: calc(var(--task-icon-size) / 2); diff --git a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_task/assistant_task.stories.tsx b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_task/assistant_task.stories.tsx index e05877622de..c1331c47c3a 100644 --- a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_task/assistant_task.stories.tsx +++ b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_task/assistant_task.stories.tsx @@ -10,17 +10,22 @@ import * as Mojom from '../../../common/mojom' import { taskConversationEntries } from '../../../page/stories/story_utils/history' import MockContext from '../../mock_untrusted_conversation_context' import AssistantTask from './assistant_task' +import { getKeysForMojomEnum } from '$web-common/mojomUtils' type CustomArgs = { isActiveTask: boolean isGenerating: boolean + isToolExecuting: boolean hasContentThumbnail: boolean + toolUseTaskState: keyof typeof Mojom.TaskState } const args: CustomArgs = { isActiveTask: false, isGenerating: false, + isToolExecuting: false, hasContentThumbnail: false, + toolUseTaskState: 'kNone' satisfies keyof typeof Mojom.TaskState, } export const _AssistantTask = { @@ -28,6 +33,9 @@ export const _AssistantTask = { return ( @@ -58,6 +65,12 @@ export const _AssistantTask = { export default { title: 'Chat/Chat', component: AssistantTask, - argTypes: InferControlsFromArgs(args), + argTypes: { + ...InferControlsFromArgs(args), + toolUseTaskState: { + options: getKeysForMojomEnum(Mojom.TaskState), + control: { type: 'select' }, + }, + }, args, } as Meta diff --git a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_task/assistant_task.tsx b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_task/assistant_task.tsx index 14ad222f5df..09754294205 100644 --- a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_task/assistant_task.tsx +++ b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_task/assistant_task.tsx @@ -5,6 +5,7 @@ import * as React from 'react' import Icon from '@brave/leo/react/icon' +import Label from '@brave/leo/react/label' import ProgressRing from '@brave/leo/react/progressRing' import Tabs from '@brave/leo/react/tabs' import TabItem from '@brave/leo/react/tabItem' @@ -13,7 +14,7 @@ import { getLocale } from '$web-common/locale' import * as Mojom from '../../../common/mojom' import { useUntrustedConversationContext } from '../../untrusted_conversation_context' import AssistantResponse from '../assistant_response' -import ToolEvent from '../assistant_response/tool_event' +import ToolEvent, { ToolEventThinking } from '../assistant_response/tool_event' import styles from './assistant_task.module.scss' import useExtractTaskData, { TaskData } from './use_extract_task_data' @@ -25,12 +26,25 @@ interface Props { // the conversation). Informs whether interactivity is allowed. isActiveTask: boolean + // Passes to AssistantResponse + isLeoModel: boolean +} + +interface TabProps { // Whether the task is actively still being generated. Informs whether the UI // displays as if it's still in progress. isGenerating: boolean - // Passes to AssistantResponse - isLeoModel: boolean + // Whether the most recent tool use request is being executed + isToolExecuting: boolean + + // Whether the task is currently generating a response + isThinking: boolean + + // What the state of the active task is + toolUseTaskState: Mojom.TaskState + + taskData: TaskData } /** @@ -84,8 +98,24 @@ export default function AssistantTask(props: Props) { const taskData = useExtractTaskData(props.assistantEntries) + const isThinking = + props.isActiveTask + && !conversationContext.isToolExecuting + && conversationContext.toolUseTaskState === Mojom.TaskState.kRunning + + const tabProps: TabProps = { + isGenerating: conversationContext.isGenerating, + isToolExecuting: conversationContext.isToolExecuting, + toolUseTaskState: conversationContext.toolUseTaskState, + isThinking: isThinking, + taskData: taskData, + } + return ( -
+
setShowSteps(!showSteps)} value={showSteps ? 'steps' : 'progress'} @@ -101,16 +131,44 @@ export default function AssistantTask(props: Props) { {showSteps && ( )} {!showSteps && ( )} + + {props.isActiveTask + && conversationContext.toolUseTaskState + === Mojom.TaskState.kPaused && ( + + )} + + {props.isActiveTask + && conversationContext.toolUseTaskState + === Mojom.TaskState.kStopped && ( + + )}
{taskThumbnail && (
@@ -122,7 +180,7 @@ export default function AssistantTask(props: Props) { ) } -function Progress(props: Props & { taskData: TaskData }) { +function Progress(props: Props & TabProps) { // The Progress tab should show: // - Any last active complete "important" tool (TODO, navigate) // - the most recent completion event @@ -155,6 +213,7 @@ function Progress(props: Props & { taskData: TaskData }) { key={index} toolUseEvent={event} isEntryActive={false} + isExecuting={false} /> ))} {currentCompletionEvent && ( @@ -173,13 +232,15 @@ function Progress(props: Props & { taskData: TaskData }) { key={index} toolUseEvent={event.toolUseEvent!} isEntryActive={props.isActiveTask} + isExecuting={props.isToolExecuting} /> ))} + {props.isThinking && }
) } -function Steps(props: Props & { taskData: TaskData }) { +function Steps(props: Props & TabProps) { // Render every event in the task, split by completion event // so that the LLM tells a story of the task by it's own progress // description. @@ -189,7 +250,7 @@ function Steps(props: Props & { taskData: TaskData }) { props.isActiveTask && index === props.taskData.taskItems.length - 1 // Are we generating this task item? - const isActive = isRunnable && props.isGenerating && props.isActiveTask + const isActive = isRunnable && props.isToolExecuting && props.isActiveTask // Are we waiting for this task item to be completed (shouldn't show as complete) const isPending = @@ -199,6 +260,8 @@ function Steps(props: Props & { taskData: TaskData }) { (event) => event.toolUseEvent && !event.toolUseEvent.output, ) + const isThinking = isRunnable && props.isThinking + return (
+ {isThinking && }
) }) diff --git a/components/ai_chat/resources/untrusted_conversation_frame/components/conversation_entries/index.tsx b/components/ai_chat/resources/untrusted_conversation_frame/components/conversation_entries/index.tsx index 8aff8e10815..36e77c10ee8 100644 --- a/components/ai_chat/resources/untrusted_conversation_frame/components/conversation_entries/index.tsx +++ b/components/ai_chat/resources/untrusted_conversation_frame/components/conversation_entries/index.tsx @@ -192,7 +192,6 @@ function ConversationEntries() { )} diff --git a/components/ai_chat/resources/untrusted_conversation_frame/untrusted_conversation_frame_api.ts b/components/ai_chat/resources/untrusted_conversation_frame/untrusted_conversation_frame_api.ts index e8a09d11ef0..6f2942cda1b 100644 --- a/components/ai_chat/resources/untrusted_conversation_frame/untrusted_conversation_frame_api.ts +++ b/components/ai_chat/resources/untrusted_conversation_frame/untrusted_conversation_frame_api.ts @@ -24,6 +24,8 @@ export type ConversationEntriesUIState = Mojom.ConversationEntriesState & { export const defaultConversationEntriesUIState: ConversationEntriesUIState = { conversationHistory: [], isGenerating: false, + isToolExecuting: false, + toolUseTaskState: Mojom.TaskState.kNone, isLeoModel: true, allModels: [], currentModelKey: '', diff --git a/components/resources/ai_chat_ui_strings.grdp b/components/resources/ai_chat_ui_strings.grdp index 562e48c3319..fe318a48e0a 100644 --- a/components/resources/ai_chat_ui_strings.grdp +++ b/components/resources/ai_chat_ui_strings.grdp @@ -888,4 +888,13 @@ Stop + + Paused + + + Stopped + + + Thinking +