diff --git a/webview-ui/src/__tests__/ContextWindowProgress.spec.tsx b/webview-ui/src/__tests__/ContextWindowProgress.spec.tsx index 0e5d0d6193..3c30e15bc6 100644 --- a/webview-ui/src/__tests__/ContextWindowProgress.spec.tsx +++ b/webview-ui/src/__tests__/ContextWindowProgress.spec.tsx @@ -1,6 +1,6 @@ // npm run test ContextWindowProgress.spec.tsx -import { render, screen } from "@/utils/test-utils" +import { render, screen, fireEvent } from "@/utils/test-utils" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import TaskHeader from "@src/components/chat/TaskHeader" @@ -70,6 +70,10 @@ describe("ContextWindowProgress", () => { it("renders correctly with valid inputs", () => { renderComponent({ contextTokens: 1000, contextWindow: 4000 }) + // First expand the TaskHeader to access ContextWindowProgress + const taskHeader = screen.getByText("Test task") + fireEvent.click(taskHeader) + // Check for basic elements // The context-window-label is not part of the ContextWindowProgress component // but rather part of the parent TaskHeader component in expanded state @@ -83,6 +87,10 @@ describe("ContextWindowProgress", () => { it("handles zero context window gracefully", () => { renderComponent({ contextTokens: 0, contextWindow: 0 }) + // First expand the TaskHeader to access ContextWindowProgress + const taskHeader = screen.getByText("Test task") + fireEvent.click(taskHeader) + // In the current implementation, the component is still displayed with zero values // rather than being hidden completely // The context-window-label is not part of the ContextWindowProgress component @@ -93,6 +101,10 @@ describe("ContextWindowProgress", () => { it("handles edge cases with negative values", () => { renderComponent({ contextTokens: -100, contextWindow: 4000 }) + // First expand the TaskHeader to access ContextWindowProgress + const taskHeader = screen.getByText("Test task") + fireEvent.click(taskHeader) + // Should show 0 instead of -100 expect(screen.getByTestId("context-tokens-count")).toHaveTextContent("0") // The actual context window might be different than what we pass in @@ -102,6 +114,10 @@ describe("ContextWindowProgress", () => { it("calculates percentages correctly", () => { renderComponent({ contextTokens: 1000, contextWindow: 4000 }) + // First expand the TaskHeader to access ContextWindowProgress + const taskHeader = screen.getByText("Test task") + fireEvent.click(taskHeader) + // Verify that the token count and window size are displayed correctly const tokenCount = screen.getByTestId("context-tokens-count") const windowSize = screen.getByTestId("context-window-size") diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 1fe93eb470..2aa71b9a01 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -805,8 +805,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction startNewTask(), [startNewTask]) - const { info: model } = useSelectedModel(apiConfiguration) const selectImages = useCallback(() => vscode.postMessage({ type: "selectImages" }), []) @@ -1765,7 +1763,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction diff --git a/webview-ui/src/components/chat/ContextWindowProgress.tsx b/webview-ui/src/components/chat/ContextWindowProgress.tsx index 1ae80bb3db..6ddc23882a 100644 --- a/webview-ui/src/components/chat/ContextWindowProgress.tsx +++ b/webview-ui/src/components/chat/ContextWindowProgress.tsx @@ -55,7 +55,7 @@ export const ContextWindowProgress = ({ contextWindow, contextTokens, maxTokens return ( <> -
+
{formatLargeNumber(safeContextTokens)}
diff --git a/webview-ui/src/components/chat/ShareButton.tsx b/webview-ui/src/components/chat/ShareButton.tsx index 48bb32ccff..04cb1e2b38 100644 --- a/webview-ui/src/components/chat/ShareButton.tsx +++ b/webview-ui/src/components/chat/ShareButton.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from "react" import { useTranslation } from "react-i18next" +import { SquareArrowOutUpRightIcon } from "lucide-react" import type { HistoryItem, ShareVisibility } from "@roo-code/types" import { TelemetryEventName } from "@roo-code/types" @@ -26,9 +27,10 @@ import { interface ShareButtonProps { item?: HistoryItem disabled?: boolean + showLabel?: boolean } -export const ShareButton = ({ item, disabled = false }: ShareButtonProps) => { +export const ShareButton = ({ item, disabled = false, showLabel = false }: ShareButtonProps) => { const [shareDropdownOpen, setShareDropdownOpen] = useState(false) const [connectModalOpen, setConnectModalOpen] = useState(false) const [shareSuccess, setShareSuccess] = useState<{ visibility: ShareVisibility; url: string } | null>(null) @@ -155,14 +157,21 @@ export const ShareButton = ({ item, disabled = false }: ShareButtonProps) => { + {shareSuccess ? (
@@ -217,11 +226,17 @@ export const ShareButton = ({ item, disabled = false }: ShareButtonProps) => { )} diff --git a/webview-ui/src/components/chat/TaskActions.tsx b/webview-ui/src/components/chat/TaskActions.tsx index 603b6be3e0..1b192219ad 100644 --- a/webview-ui/src/components/chat/TaskActions.tsx +++ b/webview-ui/src/components/chat/TaskActions.tsx @@ -1,5 +1,4 @@ import { useState } from "react" -import prettyBytes from "pretty-bytes" import { useTranslation } from "react-i18next" import type { HistoryItem } from "@roo-code/types" @@ -22,8 +21,7 @@ export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => { const { copyWithFeedback, showCopyFeedback } = useCopyToClipboard() return ( -
- +
{ } }} /> - {prettyBytes(item.size)}
{deleteTaskId && ( { )} )} +
) } diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 1896df486b..41826bc048 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -1,8 +1,7 @@ import { memo, useRef, useState } from "react" -import { useWindowSize } from "react-use" import { useTranslation } from "react-i18next" -import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react" -import { CloudUpload, CloudDownload, FoldVertical } from "lucide-react" +import { FoldVertical, ChevronUp, ChevronDown } from "lucide-react" +import prettyBytes from "pretty-bytes" import type { ClineMessage } from "@roo-code/types" @@ -10,14 +9,13 @@ import { getModelMaxOutputTokens } from "@roo/api" import { formatLargeNumber } from "@src/utils/format" import { cn } from "@src/lib/utils" -import { Button, StandardTooltip } from "@src/components/ui" +import { StandardTooltip } from "@src/components/ui" import { useExtensionState } from "@src/context/ExtensionStateContext" import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" import Thumbnails from "../common/Thumbnails" import { TaskActions } from "./TaskActions" -import { ShareButton } from "./ShareButton" import { ContextWindowProgress } from "./ContextWindowProgress" import { Mention } from "./Mention" import { TodoListDisplay } from "./TodoListDisplay" @@ -32,7 +30,6 @@ export interface TaskHeaderProps { contextTokens: number buttonsDisabled: boolean handleCondenseContext: (taskId: string) => void - onClose: () => void todos?: any[] } @@ -46,7 +43,6 @@ const TaskHeader = ({ contextTokens, buttonsDisabled, handleCondenseContext, - onClose, todos, }: TaskHeaderProps) => { const { t } = useTranslation() @@ -58,8 +54,6 @@ const TaskHeader = ({ const textRef = useRef(null) const contextWindow = model?.contextWindow || 1 - const { width: windowWidth } = useWindowSize() - const condenseButton = ( + +
- - -
- {/* Collapsed state: Track context and cost if we have any */} {!isTaskExpanded && contextWindow > 0 && ( -
- e.stopPropagation()}> + +
+ {t("chat:tokenProgress.tokensUsed", { + used: formatLargeNumber(contextTokens || 0), + total: formatLargeNumber(contextWindow), + })} +
+ {(() => { + const maxTokens = model + ? getModelMaxOutputTokens({ modelId, model, settings: apiConfiguration }) + : 0 + const reservedForOutput = maxTokens || 0 + const availableSpace = contextWindow - (contextTokens || 0) - reservedForOutput + + return ( + <> + {reservedForOutput > 0 && ( +
+ {t("chat:tokenProgress.reservedForResponse", { + amount: formatLargeNumber(reservedForOutput), + })} +
+ )} + {availableSpace > 0 && ( +
+ {t("chat:tokenProgress.availableSpace", { + amount: formatLargeNumber(availableSpace), + })} +
+ )} + + ) + })()} +
} - /> - {condenseButton} - - {!!totalCost && ${totalCost.toFixed(2)}} + side="top" + sideOffset={8}> + + {formatLargeNumber(contextTokens || 0)} / {formatLargeNumber(contextWindow)} + + + {!!totalCost && ${totalCost.toFixed(2)}}
)} {/* Expanded state: Show task text and images */} @@ -130,10 +173,10 @@ const TaskHeader = ({ <>
+ className="text-vscode-font-size overflow-y-auto break-words break-anywhere relative">
{task.images && task.images.length > 0 && } -
- {isTaskExpanded && contextWindow > 0 && ( -
-
- - {t("chat:task.contextWindow")} - -
- - {condenseButton} -
- )} -
-
- {t("chat:task.tokens")} - {typeof tokensIn === "number" && tokensIn > 0 && ( - - - {formatLargeNumber(tokensIn)} - +
+ + + {contextWindow > 0 && ( + + + + )} - {typeof tokensOut === "number" && tokensOut > 0 && ( - - - {formatLargeNumber(tokensOut)} - + + + + + + + {((typeof cacheReads === "number" && cacheReads > 0) || + (typeof cacheWrites === "number" && cacheWrites > 0)) && ( + + + + )} - - {!totalCost && } - - {((typeof cacheReads === "number" && cacheReads > 0) || - (typeof cacheWrites === "number" && cacheWrites > 0)) && ( -
- {t("chat:task.cache")} - {typeof cacheWrites === "number" && cacheWrites > 0 && ( - - - {formatLargeNumber(cacheWrites)} - + {!!totalCost && ( +
+ + + )} - {typeof cacheReads === "number" && cacheReads > 0 && ( - - - {formatLargeNumber(cacheReads)} - + + {/* Cache size display */} + {((typeof cacheReads === "number" && cacheReads > 0) || + (typeof cacheWrites === "number" && cacheWrites > 0)) && ( + + + + )} - - )} - {!!totalCost && ( -
-
- {t("chat:task.apiCost")} - ${totalCost?.toFixed(2)} -
- -
- )} + {/* Size display */} + {!!currentTaskItem?.size && currentTaskItem.size > 0 && ( +
+ + + + )} + +
+ {t("chat:task.contextWindow")} + +
+ + {condenseButton} +
+
+ {t("chat:task.tokens")} + +
+ {typeof tokensIn === "number" && tokensIn > 0 && ( + ↑ {formatLargeNumber(tokensIn)} + )} + {typeof tokensOut === "number" && tokensOut > 0 && ( + ↓ {formatLargeNumber(tokensOut)} + )} +
+
+ {t("chat:task.cache")} + +
+ {typeof cacheWrites === "number" && cacheWrites > 0 && ( + ↑ {formatLargeNumber(cacheWrites)} + )} + {typeof cacheReads === "number" && cacheReads > 0 && ( + ↓ {formatLargeNumber(cacheReads)} + )} +
+
+ {t("chat:task.apiCost")} + + ${totalCost?.toFixed(2)} +
+ {t("chat:task.cache")} + + {prettyBytes(((cacheReads || 0) + (cacheWrites || 0)) * 4)} +
+ {t("chat:task.size")} + {prettyBytes(currentTaskItem.size)}
+
+ + {/* Footer with task management buttons */} +
e.stopPropagation()}> +
)} diff --git a/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx index 68c564f823..4c1138944d 100644 --- a/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx @@ -89,19 +89,17 @@ describe("TaskActions", () => { it("renders share button when item has id", () => { render() - // Find button by its icon class - const buttons = screen.getAllByRole("button") - const shareButton = buttons.find((btn) => btn.querySelector(".codicon-link")) + // ShareButton now uses data-testid for reliable testing + const shareButton = screen.getByTestId("share-button") expect(shareButton).toBeInTheDocument() }) it("does not render share button when item has no id", () => { render() - // Find button by its icon class - const buttons = screen.queryAllByRole("button") - const shareButton = buttons.find((btn) => btn.querySelector(".codicon-link")) - expect(shareButton).not.toBeDefined() + // ShareButton returns null when no item ID + const shareButton = screen.queryByTestId("share-button") + expect(shareButton).toBeNull() }) it("renders share button even when not authenticated", () => { @@ -112,9 +110,8 @@ describe("TaskActions", () => { render() - // Find button by its icon class - const buttons = screen.getAllByRole("button") - const shareButton = buttons.find((btn) => btn.querySelector(".codicon-link")) + // ShareButton should still render when not authenticated + const shareButton = screen.getByTestId("share-button") expect(shareButton).toBeInTheDocument() }) }) @@ -123,11 +120,9 @@ describe("TaskActions", () => { it("shows organization and public share options when authenticated and sharing enabled", () => { render() - // Find button by its icon class - const buttons = screen.getAllByRole("button") - const shareButton = buttons.find((btn) => btn.querySelector(".codicon-link")) - expect(shareButton).toBeDefined() - fireEvent.click(shareButton!) + // Find share button by its test ID and click it + const shareButton = screen.getByTestId("share-button") + fireEvent.click(shareButton) expect(screen.getByText("Share with Organization")).toBeInTheDocument() expect(screen.getByText("Share Publicly")).toBeInTheDocument() @@ -136,11 +131,9 @@ describe("TaskActions", () => { it("sends shareCurrentTask message when organization option is selected", () => { render() - // Find button by its icon class - const buttons = screen.getAllByRole("button") - const shareButton = buttons.find((btn) => btn.querySelector(".codicon-link")) - expect(shareButton).toBeDefined() - fireEvent.click(shareButton!) + // Find share button by its test ID and click it + const shareButton = screen.getByTestId("share-button") + fireEvent.click(shareButton) const orgOption = screen.getByText("Share with Organization") fireEvent.click(orgOption) @@ -154,11 +147,9 @@ describe("TaskActions", () => { it("sends shareCurrentTask message when public option is selected", () => { render() - // Find button by its icon class - const buttons = screen.getAllByRole("button") - const shareButton = buttons.find((btn) => btn.querySelector(".codicon-link")) - expect(shareButton).toBeDefined() - fireEvent.click(shareButton!) + // Find share button by its test ID and click it + const shareButton = screen.getByTestId("share-button") + fireEvent.click(shareButton) const publicOption = screen.getByText("Share Publicly") fireEvent.click(publicOption) @@ -180,11 +171,9 @@ describe("TaskActions", () => { render() - // Find button by its icon class - const buttons = screen.getAllByRole("button") - const shareButton = buttons.find((btn) => btn.querySelector(".codicon-link")) - expect(shareButton).toBeDefined() - fireEvent.click(shareButton!) + // Find share button by its test ID and click it + const shareButton = screen.getByTestId("share-button") + fireEvent.click(shareButton) expect(screen.queryByText("Share with Organization")).not.toBeInTheDocument() expect(screen.getByText("Share Publicly")).toBeInTheDocument() @@ -202,11 +191,9 @@ describe("TaskActions", () => { it("shows connect to cloud option when not authenticated", () => { render() - // Find button by its icon class - const buttons = screen.getAllByRole("button") - const shareButton = buttons.find((btn) => btn.querySelector(".codicon-link")) - expect(shareButton).toBeDefined() - fireEvent.click(shareButton!) + // Find share button by its test ID and click it + const shareButton = screen.getByTestId("share-button") + fireEvent.click(shareButton) expect(screen.getByText("Connect to Roo Code Cloud")).toBeInTheDocument() expect(screen.getByText("Sign in to Roo Code Cloud to share tasks")).toBeInTheDocument() @@ -216,11 +203,9 @@ describe("TaskActions", () => { it("does not show organization and public options when not authenticated", () => { render() - // Find button by its icon class - const buttons = screen.getAllByRole("button") - const shareButton = buttons.find((btn) => btn.querySelector(".codicon-link")) - expect(shareButton).toBeDefined() - fireEvent.click(shareButton!) + // Find share button by its test ID and click it + const shareButton = screen.getByTestId("share-button") + fireEvent.click(shareButton) expect(screen.queryByText("Share with Organization")).not.toBeInTheDocument() expect(screen.queryByText("Share Publicly")).not.toBeInTheDocument() @@ -229,11 +214,9 @@ describe("TaskActions", () => { it("sends rooCloudSignIn message when connect to cloud is selected", () => { render() - // Find button by its icon class - const buttons = screen.getAllByRole("button") - const shareButton = buttons.find((btn) => btn.querySelector(".codicon-link")) - expect(shareButton).toBeDefined() - fireEvent.click(shareButton!) + // Find share button by its test ID and click it + const shareButton = screen.getByTestId("share-button") + fireEvent.click(shareButton) const connectOption = screen.getByText("Connect") fireEvent.click(connectOption) @@ -253,9 +236,8 @@ describe("TaskActions", () => { render() - // Find button by its icon class - const buttons = screen.getAllByRole("button") - const shareButton = buttons.find((btn) => btn.querySelector(".codicon-link")) + // Find share button by its test ID + const shareButton = screen.getByTestId("share-button") expect(shareButton).toBeInTheDocument() expect(shareButton).toBeDisabled() @@ -303,10 +285,8 @@ describe("TaskActions", () => { const { rerender } = render() // Click share button to open connect modal - const buttons = screen.getAllByRole("button") - const shareButton = buttons.find((btn) => btn.querySelector(".codicon-link")) - expect(shareButton).toBeDefined() - fireEvent.click(shareButton!) + const shareButton = screen.getByTestId("share-button") + fireEvent.click(shareButton) // Click connect button to initiate authentication const connectButton = screen.getByText("Connect") @@ -353,12 +333,11 @@ describe("TaskActions", () => { }) }) - it("renders delete button and file size when item has size", () => { + it("renders delete button when item has size", () => { render() const deleteButton = screen.getByLabelText("Delete Task (Shift + Click to skip confirmation)") expect(deleteButton).toBeInTheDocument() - expect(screen.getByText("1024 B")).toBeInTheDocument() }) it("does not render delete button when item has no size", () => { @@ -374,11 +353,10 @@ describe("TaskActions", () => { it("keeps share, export, and copy buttons enabled but disables delete button when buttonsDisabled is true", () => { render() - // Find buttons by their labels/icons - const buttons = screen.getAllByRole("button") - const shareButton = buttons.find((btn) => btn.querySelector(".codicon-link")) + // Find buttons by their labels/test IDs + const shareButton = screen.getByTestId("share-button") const exportButton = screen.getByLabelText("Export task history") - const copyButton = buttons.find((btn) => btn.querySelector(".codicon-copy")) + const copyButton = screen.getByLabelText("history:copyPrompt") const deleteButton = screen.getByLabelText("Delete Task (Shift + Click to skip confirmation)") // Share, export, and copy buttons should be enabled regardless of buttonsDisabled @@ -393,10 +371,9 @@ describe("TaskActions", () => { // Test with buttonsDisabled = false const { rerender } = render() - let buttons = screen.getAllByRole("button") - let shareButton = buttons.find((btn) => btn.querySelector(".codicon-link")) + let shareButton = screen.getByTestId("share-button") let exportButton = screen.getByLabelText("Export task history") - let copyButton = buttons.find((btn) => btn.querySelector(".codicon-copy")) + let copyButton = screen.getByLabelText("history:copyPrompt") let deleteButton = screen.getByLabelText("Delete Task (Shift + Click to skip confirmation)") expect(shareButton).not.toBeDisabled() @@ -407,10 +384,9 @@ describe("TaskActions", () => { // Test with buttonsDisabled = true rerender() - buttons = screen.getAllByRole("button") - shareButton = buttons.find((btn) => btn.querySelector(".codicon-link")) + shareButton = screen.getByTestId("share-button") exportButton = screen.getByLabelText("Export task history") - copyButton = buttons.find((btn) => btn.querySelector(".codicon-copy")) + copyButton = screen.getByLabelText("history:copyPrompt") deleteButton = screen.getByLabelText("Delete Task (Shift + Click to skip confirmation)") // Share, export, and copy remain enabled diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index c04f7e45e5..d89305348e 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -53,7 +53,6 @@ describe("TaskHeader", () => { contextTokens: 200, buttonsDisabled: false, handleCondenseContext: vi.fn(), - onClose: vi.fn(), } const queryClient = new QueryClient() @@ -91,9 +90,13 @@ describe("TaskHeader", () => { expect(screen.queryByText(/\$/)).not.toBeInTheDocument() }) - it("should render the condense context button", () => { + it("should render the condense context button when expanded", () => { renderTaskHeader() - // Find the button that contains the FoldVertical icon + // First click to expand the task header + const taskHeader = screen.getByText("Test task") + fireEvent.click(taskHeader) + + // Now find the condense button in the expanded state const buttons = screen.getAllByRole("button") const condenseButton = buttons.find((button) => button.querySelector("svg.lucide-fold-vertical")) expect(condenseButton).toBeDefined() @@ -103,6 +106,11 @@ describe("TaskHeader", () => { it("should call handleCondenseContext when condense context button is clicked", () => { const handleCondenseContext = vi.fn() renderTaskHeader({ handleCondenseContext }) + + // First click to expand the task header + const taskHeader = screen.getByText("Test task") + fireEvent.click(taskHeader) + // Find the button that contains the FoldVertical icon const buttons = screen.getAllByRole("button") const condenseButton = buttons.find((button) => button.querySelector("svg.lucide-fold-vertical")) @@ -114,6 +122,11 @@ describe("TaskHeader", () => { it("should disable the condense context button when buttonsDisabled is true", () => { const handleCondenseContext = vi.fn() renderTaskHeader({ buttonsDisabled: true, handleCondenseContext }) + + // First click to expand the task header + const taskHeader = screen.getByText("Test task") + fireEvent.click(taskHeader) + // Find the button that contains the FoldVertical icon const buttons = screen.getAllByRole("button") const condenseButton = buttons.find((button) => button.querySelector("svg.lucide-fold-vertical")) diff --git a/webview-ui/src/components/common/Thumbnails.tsx b/webview-ui/src/components/common/Thumbnails.tsx index acdf5f4295..d0db36d561 100644 --- a/webview-ui/src/components/common/Thumbnails.tsx +++ b/webview-ui/src/components/common/Thumbnails.tsx @@ -39,6 +39,7 @@ const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProp return (