Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 107 additions & 9 deletions packages/vscode-webui/src/components/error-message.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,127 @@
import { useIsDevMode } from "@/features/settings";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ScrollArea } from "./ui/scroll-area";

interface ErrorMessageProps {
error: { message: string } | undefined;
formatter?: (e: { message: string }) => ReactNode;
collapsible?: boolean;
viewportClassname?: string;
}

export const ErrorMessage: React.FC<ErrorMessageProps> = ({
error,
formatter,
collapsible = false,
viewportClassname,
}) => {
const [isDevMode] = useIsDevMode();
const [isExpanded, setIsExpanded] = useState(false);
const [hasOverflow, setHasOverflow] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);

const errorMessage = error?.message;

const checkOverflow = useCallback(() => {
if (collapsible && contentRef.current) {
const lineHeight = Number.parseFloat(
window.getComputedStyle(contentRef.current).lineHeight || "20",
);
const height = contentRef.current.scrollHeight;
setHasOverflow(height > lineHeight * 1.5);
}
}, [collapsible]);

// biome-ignore lint/correctness/useExhaustiveDependencies: Need to reset state when error changes
useEffect(() => {
checkOverflow();
setIsExpanded(false);
}, [errorMessage, checkOverflow]);

useEffect(() => {
if (!collapsible || !contentRef.current) {
return;
}

const resizeObserver = new ResizeObserver(() => {
checkOverflow();
});

resizeObserver.observe(contentRef.current);

return () => {
resizeObserver.disconnect();
};
}, [collapsible, checkOverflow]);

const handleToggle = () => {
if (hasOverflow) {
setIsExpanded(!isExpanded);
}
};

const isCollapsed = collapsible && hasOverflow && !isExpanded;

return (
error && (
<ScrollArea
className={cn("mb-2 break-all text-center text-error", {
"cursor-help": isDevMode,
})}
viewportClassname="max-h-32"
onClick={isDevMode ? () => console.error(error) : undefined}
>
{formatter ? formatter(error) : error.message}
</ScrollArea>
<div className="relative mb-2">
{(() => {
const content = (
<div ref={contentRef}>
{formatter ? formatter(error) : error.message}
</div>
);

return isCollapsed ? (
<div
className={cn(
"max-h-6 overflow-hidden break-all pr-8 text-center text-error",
{
"cursor-help": isDevMode,
},
)}
onClick={isDevMode ? () => console.error(error) : undefined}
>
{content}
</div>
) : (
<ScrollArea
className={cn("break-all text-center text-error", {
"cursor-help": isDevMode && (!collapsible || !hasOverflow),
"pr-8": collapsible && hasOverflow,
})}
viewportClassname={cn(
"max-h-[max(1.5rem,calc(100vh-38rem))]",
viewportClassname,
)}
onClick={
isDevMode && (!collapsible || !hasOverflow)
? () => console.error(error)
: undefined
}
>
{content}
</ScrollArea>
);
})()}
{collapsible && hasOverflow && (
<button
type="button"
onClick={handleToggle}
className="absolute top-1.5 right-2 transition-opacity hover:opacity-80"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronUpIcon className="size-4" />
) : (
<ChevronDownIcon className="size-4" />
)}
</button>
)}
</div>
)
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ interface ChatAreaProps {
isLoading: boolean;
user?: { name: string; image?: string | null };
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
className?: string;
}

export function ChatArea({
messages,
isLoading,
user,
messagesContainerRef,
className,
}: ChatAreaProps) {
const resourceUri = useResourceURI();
return (
Expand All @@ -31,6 +33,7 @@ export function ChatArea({
}}
isLoading={isLoading}
containerRef={messagesContainerRef}
className={className}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { useInlineCompactTask } from "../hooks/use-inline-compact-task";
import { useNewCompactTask } from "../hooks/use-new-compact-task";
import type { SubtaskInfo } from "../hooks/use-subtask-info";
import { ChatInputForm } from "./chat-input-form";
import { ErrorMessageView } from "./error-message-view";
import { CompleteSubtaskButton } from "./subtask";

interface ChatToolbarProps {
Expand Down Expand Up @@ -189,14 +190,19 @@ export const ChatToolbar: React.FC<ChatToolbarProps> = ({

return (
<>
<CompleteSubtaskButton subtask={subtask} messages={messages} />
<ApprovalButton
pendingApproval={pendingApproval}
retry={retry}
allowAddToolResult={allowAddToolResult}
isSubTask={isSubTask}
task={task}
/>
<div className="-translate-y-full -top-2 absolute left-0 w-full px-4 pt-1">
<div className="bg-background">
<ErrorMessageView error={displayError} />
<CompleteSubtaskButton subtask={subtask} messages={messages} />
<ApprovalButton
pendingApproval={pendingApproval}
retry={retry}
allowAddToolResult={allowAddToolResult}
isSubTask={isSubTask}
task={task}
/>
</div>
</div>
{todos && todos.length > 0 && (
<TodoList todos={todos} className="mt-2">
<TodoList.Header />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function ErrorMessageView({
return (
<ErrorMessage
error={debouncedError}
collapsible
formatter={(e) => {
if (e.message === PochiApiErrors.ReachedCreditLimit) {
return (
Expand Down
9 changes: 6 additions & 3 deletions packages/vscode-webui/src/features/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { useTranslation } from "react-i18next";

import { useLatest } from "@/lib/hooks/use-latest";
import { useMcp } from "@/lib/hooks/use-mcp";
import { cn } from "@/lib/utils";
import { useApprovalAndRetry } from "../approval";
import { getReadyForRetryError } from "../retry/hooks/use-ready-for-retry-error";
import {
Expand All @@ -37,7 +38,6 @@ import {
} from "../settings/hooks/use-tool-auto-approval";
import { ChatArea } from "./components/chat-area";
import { ChatToolbar } from "./components/chat-toolbar";
import { ErrorMessageView } from "./components/error-message-view";
import { SubtaskHeader } from "./components/subtask";
import { useRestoreTaskModel } from "./hooks/use-restore-task-model";
import { useScrollToBottom } from "./hooks/use-scroll-to-bottom";
Expand Down Expand Up @@ -326,9 +326,12 @@ function Chat({ user, uid, prompt, files }: ChatProps) {
isLoading={isLoading}
user={user || defaultUser}
messagesContainerRef={messagesContainerRef}
className={cn({
// Leave more space for errors as errors / approval button are absolutely positioned
"pb-14": !!displayError,
})}
/>
<div className="flex flex-col px-4">
<ErrorMessageView error={displayError} />
<div className="relative flex flex-col px-4">
{!isWorkspaceActive ? (
<WorkspaceRequiredPlaceholder
isFetching={isFetchingWorkspace}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export function AutoApproveMenu({ isSubTask }: { isSubTask: boolean }) {
<PopoverTrigger asChild>
<div
className={cn(
"relative mt-2 flex cursor-pointer select-none items-center justify-between py-2.5",
"relative flex cursor-pointer select-none items-center justify-between py-2.5",
)}
>
<div className="flex w-full overflow-x-hidden">
Expand Down