mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: add Show subtask button when subtask is actively running
When a parent task has an active subtask (awaitingChildId is set), a "Show subtask" button now appears in the button bar area: - If no other action buttons are present, it shows as the primary button - If action buttons are already showing (e.g. resume_task), it appears as a secondary button alongside them This prevents users from missing that a subtask is running and accidentally interacting with the wrong task. Includes 4 new tests for the Show subtask button behavior.
This commit is contained in:
parent
058ffc2ba0
commit
553b4daeb0
3 changed files with 430 additions and 51 deletions
|
|
@ -1545,7 +1545,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
vscode.postMessage({ type: "condenseTaskContextRequest", text: taskId })
|
||||
}
|
||||
|
||||
const areButtonsVisible = showScrollToBottom || primaryButtonText || secondaryButtonText
|
||||
const activeSubtaskId = currentTaskItem?.awaitingChildId
|
||||
const areButtonsVisible = showScrollToBottom || primaryButtonText || secondaryButtonText || activeSubtaskId
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -1670,59 +1671,109 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
{primaryButtonText && (
|
||||
<StandardTooltip
|
||||
content={
|
||||
primaryButtonText === t("chat:retry.title")
|
||||
? t("chat:retry.tooltip")
|
||||
: primaryButtonText === t("chat:save.title")
|
||||
? t("chat:save.tooltip")
|
||||
: primaryButtonText === t("chat:approve.title")
|
||||
? t("chat:approve.tooltip")
|
||||
: primaryButtonText === t("chat:runCommand.title")
|
||||
? t("chat:runCommand.tooltip")
|
||||
: primaryButtonText === t("chat:startNewTask.title")
|
||||
? t("chat:startNewTask.tooltip")
|
||||
: primaryButtonText === t("chat:resumeTask.title")
|
||||
? t("chat:resumeTask.tooltip")
|
||||
: primaryButtonText ===
|
||||
t("chat:proceedAnyways.title")
|
||||
? t("chat:proceedAnyways.tooltip")
|
||||
: primaryButtonText ===
|
||||
t("chat:proceedWhileRunning.title")
|
||||
? t("chat:proceedWhileRunning.tooltip")
|
||||
: undefined
|
||||
}>
|
||||
{activeSubtaskId && !primaryButtonText && !secondaryButtonText ? (
|
||||
<StandardTooltip content={t("chat:subtasks.showActiveSubtaskTooltip")}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!enableButtons}
|
||||
className={secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0"}
|
||||
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
|
||||
{primaryButtonText}
|
||||
</Button>
|
||||
</StandardTooltip>
|
||||
)}
|
||||
{secondaryButtonText && (
|
||||
<StandardTooltip
|
||||
content={
|
||||
secondaryButtonText === t("chat:startNewTask.title")
|
||||
? t("chat:startNewTask.tooltip")
|
||||
: secondaryButtonText === t("chat:reject.title")
|
||||
? t("chat:reject.tooltip")
|
||||
: secondaryButtonText === t("chat:terminate.title")
|
||||
? t("chat:terminate.tooltip")
|
||||
: secondaryButtonText === t("chat:killCommand.title")
|
||||
? t("chat:killCommand.tooltip")
|
||||
: undefined
|
||||
}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!enableButtons}
|
||||
className="flex-1 ml-[6px]"
|
||||
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
|
||||
{secondaryButtonText}
|
||||
data-testid="show-subtask-button"
|
||||
className="flex-[2] mr-0"
|
||||
onClick={() =>
|
||||
vscode.postMessage({
|
||||
type: "showTaskWithId",
|
||||
text: activeSubtaskId,
|
||||
})
|
||||
}>
|
||||
{t("chat:subtasks.showActiveSubtask")}
|
||||
</Button>
|
||||
</StandardTooltip>
|
||||
) : (
|
||||
<>
|
||||
{primaryButtonText && (
|
||||
<StandardTooltip
|
||||
content={
|
||||
primaryButtonText === t("chat:retry.title")
|
||||
? t("chat:retry.tooltip")
|
||||
: primaryButtonText === t("chat:save.title")
|
||||
? t("chat:save.tooltip")
|
||||
: primaryButtonText === t("chat:approve.title")
|
||||
? t("chat:approve.tooltip")
|
||||
: primaryButtonText === t("chat:runCommand.title")
|
||||
? t("chat:runCommand.tooltip")
|
||||
: primaryButtonText ===
|
||||
t("chat:startNewTask.title")
|
||||
? t("chat:startNewTask.tooltip")
|
||||
: primaryButtonText ===
|
||||
t("chat:resumeTask.title")
|
||||
? t("chat:resumeTask.tooltip")
|
||||
: primaryButtonText ===
|
||||
t("chat:proceedAnyways.title")
|
||||
? t("chat:proceedAnyways.tooltip")
|
||||
: primaryButtonText ===
|
||||
t(
|
||||
"chat:proceedWhileRunning.title",
|
||||
)
|
||||
? t(
|
||||
"chat:proceedWhileRunning.tooltip",
|
||||
)
|
||||
: undefined
|
||||
}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!enableButtons}
|
||||
className={
|
||||
secondaryButtonText || activeSubtaskId
|
||||
? "flex-1 mr-[6px]"
|
||||
: "flex-[2] mr-0"
|
||||
}
|
||||
onClick={() =>
|
||||
handlePrimaryButtonClick(inputValue, selectedImages)
|
||||
}>
|
||||
{primaryButtonText}
|
||||
</Button>
|
||||
</StandardTooltip>
|
||||
)}
|
||||
{activeSubtaskId && (
|
||||
<StandardTooltip content={t("chat:subtasks.showActiveSubtaskTooltip")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
data-testid="show-subtask-button"
|
||||
className="flex-1 ml-[6px]"
|
||||
onClick={() =>
|
||||
vscode.postMessage({
|
||||
type: "showTaskWithId",
|
||||
text: activeSubtaskId,
|
||||
})
|
||||
}>
|
||||
{t("chat:subtasks.showActiveSubtask")}
|
||||
</Button>
|
||||
</StandardTooltip>
|
||||
)}
|
||||
{secondaryButtonText && (
|
||||
<StandardTooltip
|
||||
content={
|
||||
secondaryButtonText === t("chat:startNewTask.title")
|
||||
? t("chat:startNewTask.tooltip")
|
||||
: secondaryButtonText === t("chat:reject.title")
|
||||
? t("chat:reject.tooltip")
|
||||
: secondaryButtonText === t("chat:terminate.title")
|
||||
? t("chat:terminate.tooltip")
|
||||
: secondaryButtonText ===
|
||||
t("chat:killCommand.title")
|
||||
? t("chat:killCommand.tooltip")
|
||||
: undefined
|
||||
}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!enableButtons}
|
||||
className="flex-1 ml-[6px]"
|
||||
onClick={() =>
|
||||
handleSecondaryButtonClick(inputValue, selectedImages)
|
||||
}>
|
||||
{secondaryButtonText}
|
||||
</Button>
|
||||
</StandardTooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,326 @@
|
|||
// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/ChatView.show-subtask-button.spec.tsx
|
||||
|
||||
import React from "react"
|
||||
import { render, waitFor, screen } from "@/utils/test-utils"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
|
||||
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
|
||||
import ChatView, { ChatViewProps } from "../ChatView"
|
||||
|
||||
// Define minimal types needed for testing
|
||||
interface ClineMessage {
|
||||
type: "say" | "ask"
|
||||
say?: string
|
||||
ask?: string
|
||||
ts: number
|
||||
text?: string
|
||||
partial?: boolean
|
||||
}
|
||||
|
||||
interface ExtensionState {
|
||||
version: string
|
||||
clineMessages: ClineMessage[]
|
||||
taskHistory: any[]
|
||||
shouldShowAnnouncement: boolean
|
||||
allowedCommands: string[]
|
||||
alwaysAllowExecute: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// Mock vscode API
|
||||
vi.mock("@src/utils/vscode", () => ({
|
||||
vscode: {
|
||||
postMessage: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock use-sound hook
|
||||
vi.mock("use-sound", () => ({
|
||||
default: vi.fn().mockImplementation(() => {
|
||||
return [vi.fn()]
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock components that use ESM dependencies
|
||||
vi.mock("../ChatRow", () => ({
|
||||
default: function MockChatRow({ message }: { message: ClineMessage }) {
|
||||
return <div data-testid="chat-row">{JSON.stringify(message)}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../AutoApproveMenu", () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
// Mock react-virtuoso
|
||||
vi.mock("react-virtuoso", () => ({
|
||||
Virtuoso: function MockVirtuoso({
|
||||
data,
|
||||
itemContent,
|
||||
}: {
|
||||
data: ClineMessage[]
|
||||
itemContent: (index: number, item: ClineMessage) => React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div data-testid="virtuoso-item-list">
|
||||
{data.map((item, index) => (
|
||||
<div key={item.ts} data-testid={`virtuoso-item-${index}`}>
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../../common/VersionIndicator", () => ({
|
||||
default: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
vi.mock("../Announcement", () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock("@/components/common/DismissibleUpsell", () => ({
|
||||
default: function MockDismissibleUpsell({ children }: { children: React.ReactNode }) {
|
||||
return <div>{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../QueuedMessages", () => ({
|
||||
QueuedMessages: () => null,
|
||||
}))
|
||||
|
||||
vi.mock("@src/components/welcome/RooTips", () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock("@src/components/welcome/RooHero", () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
// Mock i18n - return the key itself so we can match on it
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
"chat:subtasks.showActiveSubtask": "Show subtask",
|
||||
"chat:subtasks.showActiveSubtaskTooltip": "A subtask is currently running. Click to view it.",
|
||||
"chat:resumeTask.title": "Resume Task",
|
||||
"chat:terminate.title": "Terminate",
|
||||
}
|
||||
return map[key] ?? key
|
||||
},
|
||||
}),
|
||||
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||
Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => {
|
||||
return <>{children || i18nKey}</>
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock ChatTextArea
|
||||
vi.mock("../ChatTextArea", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mockReact = require("react")
|
||||
|
||||
const ChatTextAreaComponent = mockReact.forwardRef(function MockChatTextArea(
|
||||
_props: any,
|
||||
ref: React.ForwardedRef<{ focus: () => void }>,
|
||||
) {
|
||||
mockReact.useImperativeHandle(ref, () => ({
|
||||
focus: vi.fn(),
|
||||
}))
|
||||
return <div data-testid="chat-textarea" />
|
||||
})
|
||||
|
||||
return {
|
||||
default: ChatTextAreaComponent,
|
||||
ChatTextArea: ChatTextAreaComponent,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
|
||||
VSCodeButton: function MockVSCodeButton({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return <button onClick={onClick}>{children}</button>
|
||||
},
|
||||
VSCodeTextField: () => <input type="text" />,
|
||||
VSCodeLink: ({ children }: { children: React.ReactNode }) => <a>{children}</a>,
|
||||
}))
|
||||
|
||||
// Mock window.postMessage to trigger state hydration
|
||||
const sendStateMessage = (state: Partial<ExtensionState>) => {
|
||||
window.postMessage(
|
||||
{
|
||||
type: "state",
|
||||
state: {
|
||||
version: "1.0.0",
|
||||
clineMessages: [],
|
||||
taskHistory: [],
|
||||
shouldShowAnnouncement: false,
|
||||
allowedCommands: [],
|
||||
alwaysAllowExecute: false,
|
||||
cloudIsAuthenticated: false,
|
||||
...state,
|
||||
},
|
||||
},
|
||||
"*",
|
||||
)
|
||||
}
|
||||
|
||||
const defaultProps: ChatViewProps = {
|
||||
isHidden: false,
|
||||
showAnnouncement: false,
|
||||
hideAnnouncement: () => {},
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const renderChatView = (props: Partial<ChatViewProps> = {}) => {
|
||||
return render(
|
||||
<ExtensionStateContextProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChatView {...defaultProps} {...props} />
|
||||
</QueryClientProvider>
|
||||
</ExtensionStateContextProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe("ChatView - Show subtask button", () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it("shows 'Show subtask' button when parent task has awaitingChildId and no other buttons", async () => {
|
||||
renderChatView()
|
||||
|
||||
sendStateMessage({
|
||||
clineMessages: [
|
||||
{
|
||||
type: "say",
|
||||
say: "task",
|
||||
ts: Date.now() - 2000,
|
||||
text: "Parent task",
|
||||
},
|
||||
],
|
||||
currentTaskItem: {
|
||||
id: "parent-task-1",
|
||||
ts: Date.now() - 2000,
|
||||
task: "Parent task",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
awaitingChildId: "child-task-1",
|
||||
} as any,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const showSubtaskButton = screen.getByTestId("show-subtask-button")
|
||||
expect(showSubtaskButton).toBeInTheDocument()
|
||||
expect(showSubtaskButton).toHaveTextContent("chat:subtasks.showActiveSubtask")
|
||||
})
|
||||
})
|
||||
|
||||
it("navigates to child task when 'Show subtask' button is clicked", async () => {
|
||||
renderChatView()
|
||||
|
||||
sendStateMessage({
|
||||
clineMessages: [
|
||||
{
|
||||
type: "say",
|
||||
say: "task",
|
||||
ts: Date.now() - 2000,
|
||||
text: "Parent task",
|
||||
},
|
||||
],
|
||||
currentTaskItem: {
|
||||
id: "parent-task-1",
|
||||
ts: Date.now() - 2000,
|
||||
task: "Parent task",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
awaitingChildId: "child-task-1",
|
||||
} as any,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const showSubtaskButton = screen.getByTestId("show-subtask-button")
|
||||
showSubtaskButton.click()
|
||||
})
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "showTaskWithId",
|
||||
text: "child-task-1",
|
||||
})
|
||||
})
|
||||
|
||||
it("does not show 'Show subtask' button when no awaitingChildId", async () => {
|
||||
renderChatView()
|
||||
|
||||
sendStateMessage({
|
||||
clineMessages: [
|
||||
{
|
||||
type: "say",
|
||||
say: "task",
|
||||
ts: Date.now() - 2000,
|
||||
text: "Regular task",
|
||||
},
|
||||
],
|
||||
currentTaskItem: {
|
||||
id: "task-1",
|
||||
ts: Date.now() - 2000,
|
||||
task: "Regular task",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
} as any,
|
||||
})
|
||||
|
||||
// Give time for any rendering to complete
|
||||
await waitFor(() => {
|
||||
const showSubtaskButton = screen.queryByTestId("show-subtask-button")
|
||||
expect(showSubtaskButton).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it("shows 'Show subtask' button alongside primary button when both are present", async () => {
|
||||
renderChatView()
|
||||
|
||||
sendStateMessage({
|
||||
clineMessages: [
|
||||
{
|
||||
type: "say",
|
||||
say: "task",
|
||||
ts: Date.now() - 2000,
|
||||
text: "Parent task",
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "resume_task",
|
||||
ts: Date.now(),
|
||||
text: "Resume?",
|
||||
},
|
||||
],
|
||||
currentTaskItem: {
|
||||
id: "parent-task-1",
|
||||
ts: Date.now() - 2000,
|
||||
task: "Parent task",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
awaitingChildId: "child-task-1",
|
||||
} as any,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const showSubtaskButton = screen.getByTestId("show-subtask-button")
|
||||
expect(showSubtaskButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -301,7 +301,9 @@
|
|||
"resultContent": "Subtask completed",
|
||||
"defaultResult": "Please continue to the next task.",
|
||||
"completionInstructions": "You can review the results and suggest any corrections or next steps. If everything looks good, confirm to return the result to the parent task.",
|
||||
"goToSubtask": "View task"
|
||||
"goToSubtask": "View task",
|
||||
"showActiveSubtask": "Show subtask",
|
||||
"showActiveSubtaskTooltip": "A subtask is currently running. Click to view it."
|
||||
},
|
||||
"questions": {
|
||||
"hasQuestion": "Roo has a question"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue