From 0407d588dd943ededdab4c4568e9a8872ec4fd27 Mon Sep 17 00:00:00 2001 From: sw3205933776 <3205933776@qq.com> Date: Thu, 16 Oct 2025 14:58:09 +0800 Subject: [PATCH 01/14] fix: correct ordered list rendering in Markdown where all items appeared as "1." --- src/components/ChatBox/MarkDown.tsx | 16 ++++++++-------- src/components/WorkFlow/MarkDown.tsx | 16 ++++++++-------- src/style/index.css | 5 +++++ 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/components/ChatBox/MarkDown.tsx b/src/components/ChatBox/MarkDown.tsx index fd5d6ce9f..c0ce71331 100644 --- a/src/components/ChatBox/MarkDown.tsx +++ b/src/components/ChatBox/MarkDown.tsx @@ -49,7 +49,7 @@ export const MarkDown = memo( }, [content, speed, enableTypewriter, onTyping]); return ( -
+
), - ol: ({ children }) => ( -
    - {children} -
- ), + // ol: ({ children }) => ( + //
    + // {children} + //
+ // ), li: ({ children }) => (
  • {children}
  • ), diff --git a/src/components/WorkFlow/MarkDown.tsx b/src/components/WorkFlow/MarkDown.tsx index 66056736e..cdeebba81 100644 --- a/src/components/WorkFlow/MarkDown.tsx +++ b/src/components/WorkFlow/MarkDown.tsx @@ -48,7 +48,7 @@ export const MarkDown = ({ return text.replace(/\\n/g, " \n "); // add two spaces before \n, so ReactMarkdown will recognize it as a line break }; return ( -
    +
    ), - ol: ({ children }) => ( -
      - {children} -
    - ), + // ol: ({ children }) => ( + //
      + // {children} + //
    + // ), li: ({ children }) => (
  • {children}
  • ), diff --git a/src/style/index.css b/src/style/index.css index 46a11b36a..df6441f0e 100644 --- a/src/style/index.css +++ b/src/style/index.css @@ -357,4 +357,9 @@ code { .stack-login-btn, .stack-login-btn button { width: 100%; +} + +.markdown-container ol { + padding-left: 1rem; + font-size: 12px; /* text-sm */ } \ No newline at end of file From 41b9d150fc18c6eb294940e113237ed6d0a4a5be Mon Sep 17 00:00:00 2001 From: Douglasymlai Date: Wed, 22 Oct 2025 19:32:56 +0100 Subject: [PATCH 02/14] refactor: reorganize ChatBox components and enhance UX - Restructure components into MessageItem/ and TaskBox/ directories - Add FloatingAction component for task controls (pause/resume/skip) - Implement sticky task cards and improved message grouping - Add task queuing system with visual queue management - Enhance scrolling behavior with intersection observers - Improve multi-turn conversation handling and state management --- electron/main/index.ts | 2 +- src/components/ChatBox/BottomBox/index.tsx | 2 +- src/components/ChatBox/FloatingAction.tsx | 2 +- .../AgentMessageCard.tsx} | 35 ++-- .../ChatBox/MessageItem/FeedbackCard.tsx | 73 ++++++++ .../ChatBox/{ => MessageItem}/MarkDown.tsx | 31 ++-- .../ChatBox/{ => MessageItem}/NoticeCard.tsx | 6 +- .../{ => MessageItem}/SummaryMarkDown.tsx | 0 .../ChatBox/MessageItem/UserMessageCard.tsx | 157 ++++++++++++++++++ .../ChatBox/ProjectChatContainer.tsx | 11 +- src/components/ChatBox/ProjectSection.tsx | 2 +- .../ChatBox/{ => TaskBox}/TaskCard.tsx | 18 +- .../ChatBox/{ => TaskBox}/TaskItem.tsx | 2 +- .../ChatBox/{ => TaskBox}/TaskType.tsx | 0 .../{ => TaskBox}/TypeCardSkeleton.tsx | 2 +- src/components/ChatBox/UserQueryGroup.tsx | 101 ++++++----- src/components/ChatBox/index.tsx | 19 +-- src/components/Folder/index.tsx | 2 +- src/components/HistorySidebar/index.tsx | 6 +- src/components/TopBar/index.tsx | 102 +++++++----- src/components/ui/dropdown-menu.tsx | 9 +- src/pages/Dashboard/Project.tsx | 4 +- src/pages/History.tsx | 22 ++- src/pages/Home.tsx | 17 +- src/routers/index.tsx | 19 +-- src/style/index.css | 16 +- tailwind.config.js | 2 +- utils/__pycache__/__init__.cpython-310.pyc | Bin 155 -> 150 bytes .../traceroot_wrapper.cpython-310.pyc | Bin 2359 -> 2354 bytes 29 files changed, 458 insertions(+), 204 deletions(-) rename src/components/ChatBox/{MessageCard.tsx => MessageItem/AgentMessageCard.tsx} (66%) create mode 100644 src/components/ChatBox/MessageItem/FeedbackCard.tsx rename src/components/ChatBox/{ => MessageItem}/MarkDown.tsx (81%) rename src/components/ChatBox/{ => MessageItem}/NoticeCard.tsx (96%) rename src/components/ChatBox/{ => MessageItem}/SummaryMarkDown.tsx (100%) create mode 100644 src/components/ChatBox/MessageItem/UserMessageCard.tsx rename src/components/ChatBox/{ => TaskBox}/TaskCard.tsx (95%) rename src/components/ChatBox/{ => TaskBox}/TaskItem.tsx (98%) rename src/components/ChatBox/{ => TaskBox}/TaskType.tsx (100%) rename src/components/ChatBox/{ => TaskBox}/TypeCardSkeleton.tsx (98%) diff --git a/electron/main/index.ts b/electron/main/index.ts index 7c87795a2..4cccbcef0 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -985,7 +985,7 @@ async function createWindow() { transparent: true, vibrancy: 'sidebar', visualEffectState: 'active', - backgroundColor: '#00000000', + backgroundColor: '#f5f5f580', titleBarStyle: isMac ? 'hidden' : undefined, trafficLightPosition: isMac ? { x: 10, y: 10 } : undefined, icon: path.join(VITE_PUBLIC, 'favicon.ico'), diff --git a/src/components/ChatBox/BottomBox/index.tsx b/src/components/ChatBox/BottomBox/index.tsx index 2222fa292..6d1275c24 100644 --- a/src/components/ChatBox/BottomBox/index.tsx +++ b/src/components/ChatBox/BottomBox/index.tsx @@ -82,7 +82,7 @@ export default function BottomBox({
    )} {/* BoxMain */} -
    +
    {/* BoxHeader variants */} {state === "splitting" && ( diff --git a/src/components/ChatBox/FloatingAction.tsx b/src/components/ChatBox/FloatingAction.tsx index cb3dd905a..4a9ecb1ba 100644 --- a/src/components/ChatBox/FloatingAction.tsx +++ b/src/components/ChatBox/FloatingAction.tsx @@ -33,7 +33,7 @@ export const FloatingAction = ({ return (
    diff --git a/src/components/ChatBox/MessageCard.tsx b/src/components/ChatBox/MessageItem/AgentMessageCard.tsx similarity index 66% rename from src/components/ChatBox/MessageCard.tsx rename to src/components/ChatBox/MessageItem/AgentMessageCard.tsx index b5d109ebd..88fc233ba 100644 --- a/src/components/ChatBox/MessageCard.tsx +++ b/src/components/ChatBox/MessageItem/AgentMessageCard.tsx @@ -1,12 +1,11 @@ import { Copy, FileText } from "lucide-react"; import { MarkDown } from "./MarkDown"; import { useMemo } from "react"; -import { Button } from "../ui/button"; +import { Button } from "../../ui/button"; -interface MessageCardProps { +interface AgentMessageCardProps { id: string; content: string; - role: "user" | "agent"; className?: string; typewriter?: boolean; attaches?: File[]; @@ -16,15 +15,14 @@ interface MessageCardProps { // global Map to track completed typewriter effect content hash const completedTypewriterHashes = new Map(); -export function MessageCard({ +export function AgentMessageCard({ id, content, - role, typewriter = true, onTyping, className, attaches, -}: MessageCardProps) { +}: AgentMessageCardProps) { // use content hash to track if typewriter effect is completed const contentHash = useMemo(() => { return `${id}-${content}`; @@ -34,11 +32,11 @@ export function MessageCard({ const isCompleted = completedTypewriterHashes.has(contentHash); // if completed, disable typewriter effect - const enableTypewriter = role === "agent" && !isCompleted; + const enableTypewriter = !isCompleted; // when typewriter effect is completed, record to global Map const handleTypingComplete = () => { - if (role === "agent" && !isCompleted) { + if (!isCompleted) { completedTypewriterHashes.set(contentHash, true); } if (onTyping) { @@ -53,17 +51,13 @@ export function MessageCard({ return (
    - {role === "user" && ( -
    - -
    - )} +
    + +
    -
    +
    {file?.fileName?.split(".")[0]}
    @@ -98,3 +92,4 @@ export function MessageCard({
    ); } + diff --git a/src/components/ChatBox/MessageItem/FeedbackCard.tsx b/src/components/ChatBox/MessageItem/FeedbackCard.tsx new file mode 100644 index 000000000..171068063 --- /dev/null +++ b/src/components/ChatBox/MessageItem/FeedbackCard.tsx @@ -0,0 +1,73 @@ +import { Button } from "@/components/ui/button"; +import { Copy } from "lucide-react"; +import { useState } from "react"; + +interface FeedbackCardProps { + id: string; + title: string; + content: string; + onConfirm?: () => void; + onSkip?: () => void; + className?: string; +} + +export function FeedbackCard({ + id, + title, + content, + onConfirm, + onSkip, + className, +}: FeedbackCardProps) { + const [isHovered, setIsHovered] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(content); + }; + + return ( +
    setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Copy button - appears on hover */} +
    + +
    + + {/* Title */} +

    + {title} +

    + + {/* Content */} +

    + {content} +

    + + {/* Action buttons */} +
    + + +
    +
    + ); +} diff --git a/src/components/ChatBox/MarkDown.tsx b/src/components/ChatBox/MessageItem/MarkDown.tsx similarity index 81% rename from src/components/ChatBox/MarkDown.tsx rename to src/components/ChatBox/MessageItem/MarkDown.tsx index fd5d6ce9f..06ed27347 100644 --- a/src/components/ChatBox/MarkDown.tsx +++ b/src/components/ChatBox/MessageItem/MarkDown.tsx @@ -5,11 +5,11 @@ import remarkGfm from "remark-gfm"; export const MarkDown = memo( ({ content, - speed = 15, + speed = 10, onTyping, enableTypewriter = true, // Whether to enable typewriter effect - pTextSize = "text-[13px]", - olPadding = "", + pTextSize = "text-body-sm", + olPadding = "pl-3", }: { content: string; speed?: number; @@ -49,7 +49,7 @@ export const MarkDown = memo( }, [content, speed, enableTypewriter, onTyping]); return ( -
    +
    (

    {children}

    ), ul: ({ children }) => (
      {children}
    ), ol: ({ children }) => (
      {children}
    ), li: ({ children }) => ( -
  • {children}
  • +
  • {children}
  • ), code: ({ children }) => ( - + {children} ), pre: ({ children }) => ( -
    +							
     								{children}
     							
    ), @@ -117,7 +123,8 @@ export const MarkDown = memo( a: ({ children, href }) => ( diff --git a/src/components/ChatBox/NoticeCard.tsx b/src/components/ChatBox/MessageItem/NoticeCard.tsx similarity index 96% rename from src/components/ChatBox/NoticeCard.tsx rename to src/components/ChatBox/MessageItem/NoticeCard.tsx index ca093ae50..ab606a85b 100644 --- a/src/components/ChatBox/NoticeCard.tsx +++ b/src/components/ChatBox/MessageItem/NoticeCard.tsx @@ -1,10 +1,8 @@ import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; -import { TaskType } from "./TaskType"; -import { TaskItem } from "./TaskItem"; +import { TaskType } from "../TaskBox/TaskType"; +import { TaskItem } from "../TaskBox/TaskItem"; import ShinyText from "@/components/ui/ShinyText/ShinyText"; - - import { ChevronDown, SquareCode } from "lucide-react"; import { useMemo, useState, useRef, useEffect } from "react"; import useChatStoreAdapter from "@/hooks/useChatStoreAdapter"; diff --git a/src/components/ChatBox/SummaryMarkDown.tsx b/src/components/ChatBox/MessageItem/SummaryMarkDown.tsx similarity index 100% rename from src/components/ChatBox/SummaryMarkDown.tsx rename to src/components/ChatBox/MessageItem/SummaryMarkDown.tsx diff --git a/src/components/ChatBox/MessageItem/UserMessageCard.tsx b/src/components/ChatBox/MessageItem/UserMessageCard.tsx new file mode 100644 index 000000000..b42cc33b1 --- /dev/null +++ b/src/components/ChatBox/MessageItem/UserMessageCard.tsx @@ -0,0 +1,157 @@ +import { Copy, FileText, X, Image } from "lucide-react"; +import { Button } from "../../ui/button"; +import { cn } from "@/lib/utils"; +import { useState, useRef, useEffect } from "react"; + +interface UserMessageCardProps { + id: string; + content: string; + className?: string; + attaches?: File[]; +} + +export function UserMessageCard({ + id, + content, + className, + attaches, +}: UserMessageCardProps) { + const [hoveredFilePath, setHoveredFilePath] = useState(null); + const [isRemainingOpen, setIsRemainingOpen] = useState(false); + const remainingRef = useRef(null); + + const handleCopy = () => { + navigator.clipboard.writeText(content); + }; + + useEffect(() => { + const onDocClick = (e: MouseEvent) => { + if (!remainingRef.current) return; + if (!remainingRef.current.contains(e.target as Node)) { + setIsRemainingOpen(false); + } + }; + document.addEventListener("mousedown", onDocClick); + return () => document.removeEventListener("mousedown", onDocClick); + }, []); + + const getFileIcon = (fileName: string) => { + const ext = fileName.split(".").pop()?.toLowerCase() || ""; + if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) { + return ; + } + return ; + }; + + return ( +
    +
    + +
    +
    + {content} +
    + {attaches && attaches.length > 0 && ( +
    + {(() => { + // Show max 4 files + count indicator + const maxVisibleFiles = 4; + const visibleFiles = attaches.slice(0, maxVisibleFiles); + const remainingCount = attaches.length > maxVisibleFiles ? attaches.length - maxVisibleFiles : 0; + + return ( + <> + {visibleFiles.map((file) => { + const isHovered = hoveredFilePath === file.filePath; + return ( +
    setHoveredFilePath(file.filePath)} + onMouseLeave={() => setHoveredFilePath((prev) => (prev === file.filePath ? null : prev))} + onClick={(e) => { + e.stopPropagation(); + window.ipcRenderer.invoke("reveal-in-folder", file.filePath); + }} + > + {/* File icon */} +
    + {getFileIcon(file.fileName)} +
    + + {/* File Name */} +

    + {file.fileName} +

    +
    + ); + })} + + {/* Show remaining count if more than 4 files */} + {remainingCount > 0 && ( +
    + + {isRemainingOpen && ( +
    +
    + {attaches.slice(maxVisibleFiles, maxVisibleFiles + 5).map((file) => { + const isHovered = hoveredFilePath === file.filePath; + return ( +
    setHoveredFilePath(file.filePath)} + onMouseLeave={() => setHoveredFilePath((prev) => (prev === file.filePath ? null : prev))} + onClick={(e) => { + e.stopPropagation(); + window.ipcRenderer.invoke("reveal-in-folder", file.filePath); + setIsRemainingOpen(false); + }} + > +
    + {getFileIcon(file.fileName)} +
    +

    + {file.fileName} +

    +
    + ); + })} +
    +
    + )} +
    + )} + + ); + })()} +
    + )} +
    + ); +} + diff --git a/src/components/ChatBox/ProjectChatContainer.tsx b/src/components/ChatBox/ProjectChatContainer.tsx index 1f4f0e7b8..15a3fcb94 100644 --- a/src/components/ChatBox/ProjectChatContainer.tsx +++ b/src/components/ChatBox/ProjectChatContainer.tsx @@ -134,7 +134,7 @@ export const ProjectChatContainer: React.FC = ({ return (
    {chatStores.map(({ chatId, chatStore }) => { @@ -146,9 +146,12 @@ export const ProjectChatContainer: React.FC = ({ } const task = chatState.tasks[activeTaskId]; - const hasMessages = task.messages.length > 0 || task.hasMessages; - - if (!hasMessages) { + const messages = task.messages || []; + + // Only render if there are actual user messages (not just empty or system messages) + const hasUserMessages = messages.some((msg: any) => msg.role === 'user' && msg.content); + + if (!hasUserMessages) { return null; } diff --git a/src/components/ChatBox/ProjectSection.tsx b/src/components/ChatBox/ProjectSection.tsx index c48c00f90..ae361674a 100644 --- a/src/components/ChatBox/ProjectSection.tsx +++ b/src/components/ChatBox/ProjectSection.tsx @@ -43,7 +43,7 @@ export const ProjectSection = React.forwardRef {/* User Query Groups */}
    diff --git a/src/components/ChatBox/TaskCard.tsx b/src/components/ChatBox/TaskBox/TaskCard.tsx similarity index 95% rename from src/components/ChatBox/TaskCard.tsx rename to src/components/ChatBox/TaskBox/TaskCard.tsx index 26d7663f7..1815c5b0a 100644 --- a/src/components/ChatBox/TaskCard.tsx +++ b/src/components/ChatBox/TaskBox/TaskCard.tsx @@ -21,7 +21,7 @@ import { CircleSlash, } from "lucide-react"; import { useMemo, useState, useRef, useEffect } from "react"; -import { TaskState, TaskStateType } from "../TaskState"; +import { TaskState, TaskStateType } from "@/components/TaskState"; import useChatStoreAdapter from "@/hooks/useChatStoreAdapter"; interface TaskCardProps { @@ -171,16 +171,16 @@ export function TaskCard({ return (
    -
    -
    +
    +
    -
    - {summaryTask - ? summaryTask.split("|")[0].replace(/"/g, "") - : "Thinking hard..."} -
    + {summaryTask && ( +
    + {summaryTask.split("|")[0].replace(/"/g, "")} +
    + )} {summaryTask && (
    @@ -405,7 +405,7 @@ export function TaskCard({
    -
    +
    diff --git a/src/components/ChatBox/UserQueryGroup.tsx b/src/components/ChatBox/UserQueryGroup.tsx index 7776bba68..3a3380309 100644 --- a/src/components/ChatBox/UserQueryGroup.tsx +++ b/src/components/ChatBox/UserQueryGroup.tsx @@ -1,10 +1,12 @@ import React, { useRef, useEffect, useState } from 'react'; import { motion, useMotionValue, useTransform } from 'framer-motion'; -import { MessageCard } from './MessageCard'; -import { NoticeCard } from './NoticeCard'; -import { TypeCardSkeleton } from './TypeCardSkeleton'; -import { TaskCard } from './TaskCard'; +import { UserMessageCard } from './MessageItem/UserMessageCard'; +import { AgentMessageCard } from './MessageItem/AgentMessageCard'; +import { NoticeCard } from './MessageItem/NoticeCard'; +import { TypeCardSkeleton } from './TaskBox/TypeCardSkeleton'; +import { TaskCard } from './TaskBox/TaskCard'; import { VanillaChatStore } from '@/store/chatStore'; +import { FileText } from 'lucide-react'; interface QueryGroup { queryId: string; @@ -36,17 +38,19 @@ export const UserQueryGroup: React.FC = ({ const chatState = chatStore.getState(); const activeTaskId = chatState.activeTaskId; - // Show task if this query group has a task message OR if it's the most recent user query during splitting - // During splitting phase (no to_sub_tasks yet), show task for the most recent query only - const isLastUserQuery = !queryGroup.taskMessage && - activeTaskId && - chatState.tasks[activeTaskId] && - queryGroup.userMessage && - queryGroup.userMessage.id === chatState.tasks[activeTaskId].messages.filter((m: any) => m.role === 'user').pop()?.id && - // Only show during active phases (not finished) - chatState.tasks[activeTaskId].status !== 'finished'; + // Get the active task + const activeTask = activeTaskId ? chatState.tasks[activeTaskId] : null; - const task = (queryGroup.taskMessage || isLastUserQuery) && activeTaskId ? chatState.tasks[activeTaskId] : null; + // Check if this query group's user message matches the first user message in the active task + // This handles the splitting state before taskMessage is added to the group + const isQueryGroupForActiveTask = activeTask && + activeTask.messages.length > 0 && + activeTask.messages[0].id === queryGroup.userMessage?.id; + + // Show task if this query group has a task message OR if it's the query for the active task + const task = (queryGroup.taskMessage || isQueryGroupForActiveTask) && activeTaskId + ? chatState.tasks[activeTaskId] + : null; // Set up intersection observer for this query group useEffect(() => { @@ -113,10 +117,13 @@ export const UserQueryGroup: React.FC = ({ }, [task]); // Check if we're in skeleton phase + const anyToSubTasksMessage = task?.messages.find((m: any) => m.step === "to_sub_tasks"); const isSkeletonPhase = task && ( - (!task.messages.find((m: any) => m.step === "to_sub_tasks") && - !task.hasWaitComfirm && task.messages.length > 0) || - task.isTakeControl + (task.status !== 'finished' && + !anyToSubTasksMessage && + !task.hasWaitComfirm && + task.messages.length > 0) || + (task.isTakeControl && !anyToSubTasksMessage) ); return ( @@ -136,19 +143,17 @@ export const UserQueryGroup: React.FC = ({ initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }} - className="px-2 py-sm" + className="pl-sm py-sm" > - {}} attaches={queryGroup.userMessage.attaches} /> - {/* Sticky Task Box - Show for each query group that has a task */} - {task && ( + {/* Sticky Task Box - Show only when task exists and NOT in skeleton phase */} + {task && !isSkeletonPhase && ( = ({ initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, - y: 0, - paddingTop: isTaskBoxSticky ? 0 : 8, - paddingBottom: isTaskBoxSticky ? 0 : 8, - paddingLeft: isTaskBoxSticky ? 0 : 8, - paddingRight: isTaskBoxSticky ? 0 : 8 + y: 0 }} transition={{ duration: 0.3, - delay: 0.1, // Slight delay for sequencing - paddingTop: { duration: 0.3, ease: "easeInOut" }, - paddingBottom: { duration: 0.3, ease: "easeInOut" }, - paddingLeft: { duration: 0.3, ease: "easeInOut" }, - paddingRight: { duration: 0.3, ease: "easeInOut" } + delay: 0.1 // Slight delay for sequencing }} >
    = ({ initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} - className="flex flex-col gap-4" + className="flex flex-col pl-3 gap-4" > - {}} /> {/* File List */} {message.fileList && ( -
    +
    {message.fileList.map((file: any) => ( = ({ ); } else if (message.content === "skip") { return ( - + {}} /> + ); } else { return ( - + {}} attaches={message.attaches} /> + ); } } else if (message.step === "end" && message.content === "") { @@ -294,7 +304,7 @@ export const UserQueryGroup: React.FC = ({ initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }} - className="flex flex-col gap-4" + className="flex flex-col pl-3 gap-4" > {message.fileList && (
    @@ -308,10 +318,11 @@ export const UserQueryGroup: React.FC = ({ chatState.setSelectedFile(activeTaskId as string, file); chatState.setActiveWorkSpace(activeTaskId as string, "documentWorkSpace"); }} - className="flex items-center gap-2 bg-message-fill-default rounded-sm px-2 py-1 w-[140px] cursor-pointer hover:bg-message-fill-hover transition-colors" - > + className="flex items-center gap-2 bg-message-fill-default rounded-2xl px-2 py-1 w-[120px] cursor-pointer hover:bg-message-fill-hover transition-colors" + > +
    -
    +
    {file.name.split(".")[0]}
    diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index 7e3f8af3f..224ca0d18 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -611,10 +611,11 @@ export default function ChatBox(): JSX.Element { // Check if any chat store in the project has messages const hasAnyMessages = useMemo(() => { // First check current active chat store - if (chatStore.activeTaskId && - (chatStore.tasks[chatStore.activeTaskId].messages.length > 0 || - chatStore.tasks[chatStore.activeTaskId as string]?.hasMessages)) { - return true; + if (chatStore.activeTaskId && chatStore.tasks[chatStore.activeTaskId]) { + const activeTask = chatStore.tasks[chatStore.activeTaskId]; + if ((activeTask.messages && activeTask.messages.length > 0) || activeTask.hasMessages) { + return true; + } } // Then check all other chat stores in the project @@ -628,11 +629,9 @@ export default function ChatBox(): JSX.Element { }, [chatStore, getAllChatStoresMemoized]); return ( -
    +
    {hasAnyMessages ? ( -
    -
    - +
    {/* New Project Chat Container */} ) : ( // Init ChatBox -
    -
    +
    +
    diff --git a/src/components/Folder/index.tsx b/src/components/Folder/index.tsx index a8e734bf1..aed6ae65f 100644 --- a/src/components/Folder/index.tsx +++ b/src/components/Folder/index.tsx @@ -13,7 +13,7 @@ import { import { Button } from "@/components/ui/button"; import FolderComponent from "./FolderComponent"; -import { MarkDown } from "@/components/ChatBox/MarkDown"; +import { MarkDown } from "@/components/ChatBox/MessageItem/MarkDown"; import { useAuthStore } from "@/store/authStore"; import { proxyFetchGet } from "@/api/http"; import { useTranslation } from "react-i18next"; diff --git a/src/components/HistorySidebar/index.tsx b/src/components/HistorySidebar/index.tsx index 26686915e..4d238749b 100644 --- a/src/components/HistorySidebar/index.tsx +++ b/src/components/HistorySidebar/index.tsx @@ -330,7 +330,7 @@ export default function HistorySidebar() { />
    - {task?.messages[0]?.content || t("layout.new-project")} + {task?.messages?.[0]?.content || t("layout.new-project")}
    - {task?.messages[0]?.content || t("layout.new-project")} + {task?.messages?.[0]?.content || t("layout.new-project")}

    } className="w-[300px] bg-surface-tertiary p-2 text-wrap break-words text-label-xs select-text pointer-events-auto shadow-perfect" > - {task?.messages[0]?.content || t("dashboard.new-project")} + {task?.messages?.[0]?.content || t("dashboard.new-project")}
    diff --git a/src/components/TopBar/index.tsx b/src/components/TopBar/index.tsx index e20aea664..54053ea94 100644 --- a/src/components/TopBar/index.tsx +++ b/src/components/TopBar/index.tsx @@ -14,10 +14,17 @@ import { ChevronLeft, House, Share, + MoreHorizontal, } from "lucide-react"; import "./index.css"; import folderIcon from "@/assets/Folder.svg"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { useLocation, useNavigate } from "react-router-dom"; import { useSidebarStore } from "@/store/sidebarStore"; import useChatStoreAdapter from "@/hooks/useChatStoreAdapter"; @@ -140,6 +147,8 @@ function HeaderWin() { const handleEndProject = async () => { const taskId = chatStore.activeTaskId; + const currentProjectId = projectStore.activeProjectId; + if (!taskId) { toast.error(t("layout.no-active-project-to-end")); return; @@ -172,9 +181,9 @@ function HeaderWin() { // Remove from local store chatStore.removeTask(taskId); - // Create a new project - const newTaskId = chatStore.create(); - chatStore.setActiveTaskId(newTaskId); + // Create a completely new project instead of just a new task + // This ensures we start fresh without any residual state + projectStore.createProject("new project"); // Navigate to home navigate("/"); @@ -284,49 +293,6 @@ function HeaderWin() { platform === "darwin" && "pr-2" } flex h-full items-center space-x-1 z-50 relative no-drag gap-1`} > - {chatStore.activeTaskId && chatStore.tasks[chatStore.activeTaskId as string] && ( - <> - - - - - )} - - - - {chatStore.activeTaskId && - chatStore.tasks[chatStore.activeTaskId as string]?.status === 'finished' && ( - - - - )} {chatStore.activeTaskId && chatStore.tasks[chatStore.activeTaskId as string] && ( @@ -346,6 +312,50 @@ function HeaderWin() { )} + {chatStore.activeTaskId && + chatStore.tasks[chatStore.activeTaskId as string]?.status === 'finished' && ( + + + + )} + + + + + + {chatStore.activeTaskId && chatStore.tasks[chatStore.activeTaskId as string] && ( + + + {t("layout.report-bug")} + + )} + + gift-icon + {t("layout.refer-friends")} + + navigate("/history?tab=settings")} className="cursor-pointer"> + + {t("layout.settings")} + + +
    )} {location.pathname === "/history" && ( diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 86f5bcc82..648a44e0c 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -63,10 +63,15 @@ const DropdownMenuContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border border-solid border-zinc-200 bg-white-100% p-xs text-popover-foreground shadow-md", + "z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden p-xs text-popover-foreground shadow-md", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]", className )} + style={{ + borderRadius: 'var(--borderRadius-rounded-xl, 0.75rem)', + border: '1px solid var(--dropdown-border, #CCC)', + background: 'var(--dropdown-bg, #FFF)' + }} {...props} /> @@ -82,7 +87,7 @@ const DropdownMenuItem = React.forwardRef< svg]:size-4 [&>svg]:shrink-0", + "relative flex cursor-default select-none items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-none transition-colors bg-menubutton-fill-default data-[highlighted]:bg-menubutton-fill-hover focus:bg-menubutton-fill-active focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", inset && "pl-8", className )} diff --git a/src/pages/Dashboard/Project.tsx b/src/pages/Dashboard/Project.tsx index 0153c7954..7512a54f4 100644 --- a/src/pages/Dashboard/Project.tsx +++ b/src/pages/Dashboard/Project.tsx @@ -379,11 +379,11 @@ export default function Project() {
    0 && + task.taskAssigning && task.taskAssigning.length > 0 && "border-x border-white-100%" }`} > - {task.taskAssigning.map((taskAssigning) => ( + {task.taskAssigning && task.taskAssigning.map((taskAssigning) => (
    Loading...
    ; } - const [activeTab, setActiveTab] = useState<"projects" | "workers" | "trigger" | "settings" | "mcp_tools">("projects"); + + // Get initial tab from URL parameter, default to "projects" + const getInitialTab = () => { + const tabFromUrl = searchParams.get('tab'); + const validTabs = ["projects", "workers", "trigger", "settings", "mcp_tools"]; + return validTabs.includes(tabFromUrl || "") ? tabFromUrl as typeof activeTab : "projects"; + }; + + const [activeTab, setActiveTab] = useState<"projects" | "workers" | "trigger" | "settings" | "mcp_tools">(getInitialTab); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const scrollContainerRef = useRef(null); const HAS_STACK_KEYS = hasStackKeys(); @@ -70,7 +79,14 @@ export default function Home() { navigate("/"); }; - useEffect(() => {}, []); + useEffect(() => { + // Update active tab when URL parameter changes + const tabFromUrl = searchParams.get('tab'); + const validTabs = ["projects", "workers", "trigger", "settings", "mcp_tools"]; + if (tabFromUrl && validTabs.includes(tabFromUrl)) { + setActiveTab(tabFromUrl as typeof activeTab); + } + }, [searchParams]); return (
    diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index dea644d6d..414e9f89e 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -75,19 +75,18 @@ export default function Home() { // capture webview const captureWebview = async () => { - if ( - chatStore.tasks[chatStore.activeTaskId as string].status === "finished" - ) { + const activeTask = chatStore.tasks[chatStore.activeTaskId as string]; + if (!activeTask || activeTask.status === "finished") { return; } webviews.map((webview) => { window.ipcRenderer .invoke("capture-webview", webview.id) .then((base64: string) => { - if (chatStore.tasks[chatStore.activeTaskId as string].type) return; + const currentTask = chatStore.tasks[chatStore.activeTaskId as string]; + if (!currentTask || currentTask.type) return; let taskAssigning = [ - ...chatStore.tasks[chatStore.activeTaskId as string] - .taskAssigning, + ...currentTask.taskAssigning, ]; const searchAgentIndex = taskAssigning.findIndex( (agent) => agent.agent_id === webview.agent_id @@ -186,14 +185,12 @@ export default function Home() { }; return ( -
    +
    -
    +
    -
    -
    diff --git a/src/routers/index.tsx b/src/routers/index.tsx index a7dfdcb7b..193766bd5 100644 --- a/src/routers/index.tsx +++ b/src/routers/index.tsx @@ -8,14 +8,7 @@ const Login = lazy(() => import("@/pages/Login")); const Signup = lazy(() => import("@/pages/SignUp")); const Home = lazy(() => import("@/pages/Home")); const History = lazy(() => import("@/pages/History")); -const Setting = lazy(() => import("@/pages/Setting")); const NotFound = lazy(() => import("@/pages/NotFound")); -const SettingGeneral = lazy(() => import("@/pages/Setting/General")); -const SettingPrivacy = lazy(() => import("@/pages/Setting/Privacy")); -const SettingModels = lazy(() => import("@/pages/Setting/Models")); -const SettingAPI = lazy(() => import("@/pages/Setting/API")); -const SettingMCP = lazy(() => import("@/pages/Setting/MCP")); -const MCPMarket = lazy(() => import("@/pages/Setting/MCPMarket")); // Route guard: Check if user is logged in const ProtectedRoute = () => { @@ -49,16 +42,8 @@ const AppRoutes = () => ( }> } /> } /> - }> - {/* Setting sub-routes */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + } /> + } /> } /> diff --git a/src/style/index.css b/src/style/index.css index 1be6367b5..1f3e1c235 100644 --- a/src/style/index.css +++ b/src/style/index.css @@ -39,7 +39,6 @@ body { width: 100%; height: 100%; overflow: hidden; - border-radius: 8px; } @@ -92,17 +91,16 @@ body { /* Custom ResizableHandle Styles */ .custom-resizable-handle { - width: 4px; + width: 1px; height: 100%; - background: transparent; + background: rgba(200,200,200,0.3); transition: all 0.2s ease; position: relative; } .custom-resizable-handle:hover { - background: var(--border-primary); - width: 4px; - border-radius: 20px; + background: var(--border-information); + width: 1px; height: 100%; transform: none; } @@ -260,15 +258,15 @@ code { } .scrollbar.scrolling::-webkit-scrollbar-thumb { - background-color: rgba(156, 163, 175, 0.8); + background-color: rgba(156, 163, 175, 0.2); } .scrollbar::-webkit-scrollbar-thumb:hover { - background-color: rgba(156, 163, 175, 0.8); + background-color: rgba(156, 163, 175, 0.2); } .scrollbar::-webkit-scrollbar-track { - background: rgba(0, 0, 0, 0.05); + background: transparent; } .scrollbar-always-visible { diff --git a/tailwind.config.js b/tailwind.config.js index 791ea77a4..1293711ab 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -363,7 +363,7 @@ module.exports = { }, menubutton: { "fill-default": "var(--menubutton-fill-default)", - "fill-hover": "var(--menubutton-fill-active)", + "fill-hover": "var(--menubutton-fill-hover)", "fill-active": "var(--menubutton-fill-active)", "border-active": "var(--menubutton-border-active)", "border-default": "var(--menubutton-border-default)", diff --git a/utils/__pycache__/__init__.cpython-310.pyc b/utils/__pycache__/__init__.cpython-310.pyc index 664fc47bd9826a3e449c88dcd75e10bb0081579c..ff3c84b3e30be08256dc9dc392a9678165af8d8a 100644 GIT binary patch delta 55 zcmbQuIE|4jpO=@50SJNuCUV&-TIz=urxq3Kr{tHW=Oh;EyQCIpm*f}dJLjjQDmWz; Jr%ue(0|1Zq5Pkpv delta 60 zcmbQnIGd3xpO=@50SIEYPvo*yv(pbPPAw|dPt7Yz&D3{EEzT~e)=$YVP0vXz)^|xQ&MwI>(09&HNmX!4 MEKc40kSUA}0OV&9Jpcdz delta 63 zcmdlav|WfRpO=@50SIEYZ{*s@q~@+4TAW%`te=`!l$xpUl3JWyl3$=-lv Date: Thu, 23 Oct 2025 10:12:00 +0100 Subject: [PATCH 03/14] top bar fixed --- src/components/TopBar/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/TopBar/index.tsx b/src/components/TopBar/index.tsx index a1e099c77..dfaa2f929 100644 --- a/src/components/TopBar/index.tsx +++ b/src/components/TopBar/index.tsx @@ -242,7 +242,6 @@ function HeaderWin() { onClick={() => navigate("/")} > - {t("layout.back")}
    )} From 86748275171de642eba612077cda40194b18a196 Mon Sep 17 00:00:00 2001 From: luo <479933015@qq.com> Date: Thu, 6 Nov 2025 11:08:14 +0800 Subject: [PATCH 04/14] feat(chat_controller): add SSE timeout handling and improve logging structure --- backend/app/controller/chat_controller.py | 93 ++++++++++++++++++----- 1 file changed, 72 insertions(+), 21 deletions(-) diff --git a/backend/app/controller/chat_controller.py b/backend/app/controller/chat_controller.py index d9afc4acd..338537845 100644 --- a/backend/app/controller/chat_controller.py +++ b/backend/app/controller/chat_controller.py @@ -1,6 +1,7 @@ import asyncio import os import re +import time from pathlib import Path from dotenv import load_dotenv from fastapi import APIRouter, HTTPException, Request, Response @@ -8,7 +9,7 @@ from fastapi.responses import StreamingResponse from utils import traceroot_wrapper as traceroot from app.component import code from app.exception.exception import UserException -from app.model.chat import Chat, HumanReply, McpServers, Status, SupplementChat, AddTaskRequest +from app.model.chat import Chat, HumanReply, McpServers, Status, SupplementChat, AddTaskRequest, sse_json from app.service.chat_service import step_solve from app.service.task import ( Action, @@ -30,13 +31,51 @@ from camel.tasks.task import Task router = APIRouter(tags=["chat"]) # Create traceroot logger for chat controller -chat_logger = traceroot.get_logger('chat_controller') +chat_logger = traceroot.get_logger("chat_controller") + +# SSE timeout configuration (10 minutes in seconds) +SSE_TIMEOUT_SECONDS = 10 * 60 + + +async def timeout_stream_wrapper(stream_generator, timeout_seconds: int = SSE_TIMEOUT_SECONDS): + last_data_time = [time.time()] + generator = stream_generator.__aiter__() + should_stop = False + + try: + while not should_stop: + elapsed = time.time() - last_data_time[0] + remaining_timeout = max(0, timeout_seconds - elapsed) + + if elapsed >= timeout_seconds: + chat_logger.warning(f"SSE timeout: No data received for {elapsed:.1f} seconds, closing connection") + yield sse_json("timeout", {"message": "Connection timeout: No data received for 10 minutes"}) + break + try: + data = await asyncio.wait_for(generator.__anext__(), timeout=remaining_timeout) + last_data_time[0] = time.time() + yield data + except asyncio.TimeoutError: + chat_logger.warning(f"SSE timeout: No data received for {timeout_seconds} seconds, closing connection") + yield sse_json("timeout", {"message": "Connection timeout: No data received for 10 minutes"}) + break + except StopAsyncIteration: + break + + except asyncio.CancelledError: + chat_logger.info("Stream cancelled") + raise + except Exception as e: + chat_logger.error(f"Error in stream wrapper: {e}", exc_info=True) + raise @router.post("/chat", name="start chat") @traceroot.trace() async def post(data: Chat, request: Request): - chat_logger.info("Starting new chat session", extra={"project_id": data.project_id, "task_id": data.task_id, "user": data.email}) + chat_logger.info( + "Starting new chat session", extra={"project_id": data.project_id, "task_id": data.task_id, "user": data.email} + ) task_lock = get_or_create_task_lock(data.project_id) # Set user-specific environment path for this thread @@ -50,7 +89,14 @@ async def post(data: Chat, request: Request): os.environ["CAMEL_MODEL_LOG_ENABLED"] = "true" email_sanitized = re.sub(r'[\\/*?:"<>|\s]', "_", data.email.split("@")[0]).strip(".") - camel_log = Path.home() / ".eigent" / email_sanitized / ("project_" + data.project_id) / ("task_" + data.task_id) / "camel_logs" + camel_log = ( + Path.home() + / ".eigent" + / email_sanitized + / ("project_" + data.project_id) + / ("task_" + data.task_id) + / "camel_logs" + ) camel_log.mkdir(parents=True, exist_ok=True) os.environ["CAMEL_LOG_DIR"] = str(camel_log) @@ -61,8 +107,13 @@ async def post(data: Chat, request: Request): # Put initial action in queue to start processing await task_lock.put_queue(ActionImproveData(data=data.question)) - chat_logger.info("Chat session initialized, starting streaming response", extra={"project_id": data.project_id, "task_id": data.task_id, "log_dir": str(camel_log)}) - return StreamingResponse(step_solve(data, request, task_lock), media_type="text/event-stream") + chat_logger.info( + "Chat session initialized, starting streaming response", + extra={"project_id": data.project_id, "task_id": data.task_id, "log_dir": str(camel_log)}, + ) + return StreamingResponse( + timeout_stream_wrapper(step_solve(data, request, task_lock)), media_type="text/event-stream" + ) @router.post("/chat/{id}", name="improve chat") @@ -77,14 +128,14 @@ def improve(id: str, data: SupplementChat): # Reset status to allow processing new messages task_lock.status = Status.confirming # Clear any existing background tasks since workforce was stopped - if hasattr(task_lock, 'background_tasks'): + if hasattr(task_lock, "background_tasks"): task_lock.background_tasks.clear() # Note: conversation_history and last_task_result are preserved # Log context preservation - if hasattr(task_lock, 'conversation_history'): + if hasattr(task_lock, "conversation_history"): chat_logger.info(f"[CONTEXT] Preserved {len(task_lock.conversation_history)} conversation entries") - if hasattr(task_lock, 'last_task_result'): + if hasattr(task_lock, "last_task_result"): chat_logger.info(f"[CONTEXT] Preserved task result: {len(task_lock.last_task_result)} chars") # Update file save path if task_id is provided @@ -93,7 +144,7 @@ def improve(id: str, data: SupplementChat): try: # Get current environment values needed to construct new path current_email = None - + # Extract email from current file_save_path if available current_file_save_path = os.environ.get("file_save_path", "") if current_file_save_path: @@ -102,7 +153,7 @@ def improve(id: str, data: SupplementChat): eigent_index = path_parts.index("eigent") if eigent_index + 1 < len(path_parts): current_email = path_parts[eigent_index + 1] - + # If we have the necessary information, update the file_save_path if current_email and id: # Create new path using the existing pattern: email/project_{project_id}/task_{task_id} @@ -110,12 +161,12 @@ def improve(id: str, data: SupplementChat): new_folder_path.mkdir(parents=True, exist_ok=True) os.environ["file_save_path"] = str(new_folder_path) chat_logger.info(f"Updated file_save_path to: {new_folder_path}") - + # Store the new folder path in task_lock for potential cleanup and persistence task_lock.new_folder_path = new_folder_path else: chat_logger.warning(f"Could not update file_save_path - email: {current_email}, project_id: {id}") - + except Exception as e: chat_logger.error(f"Error updating file path for project_id: {id}, task_id: {data.task_id}: {e}") @@ -160,7 +211,7 @@ def human_reply(id: str, data: HumanReply): @router.post("/chat/{id}/install-mcp") @traceroot.trace() def install_mcp(id: str, data: McpServers): - chat_logger.info("Installing MCP servers", extra={"task_id": id, "servers_count": len(data.get('mcpServers', {}))}) + chat_logger.info("Installing MCP servers", extra={"task_id": id, "servers_count": len(data.get("mcpServers", {}))}) task_lock = get_task_lock(id) asyncio.run(task_lock.put_queue(ActionInstallMcpData(action=Action.install_mcp, data=data))) chat_logger.info("MCP installation queued", extra={"task_id": id}) @@ -173,7 +224,7 @@ def add_task(id: str, data: AddTaskRequest): """Add a new task to the workforce""" chat_logger.info(f"Adding task to workforce for task_id: {id}, content: {data.content[:100]}...") task_lock = get_task_lock(id) - + try: # Queue the add task action add_task_action = ActionAddTaskData( @@ -181,11 +232,11 @@ def add_task(id: str, data: AddTaskRequest): project_id=data.project_id, task_id=data.task_id, additional_info=data.additional_info, - insert_position=data.insert_position + insert_position=data.insert_position, ) asyncio.run(task_lock.put_queue(add_task_action)) return Response(status_code=201) - + except Exception as e: chat_logger.error(f"Error adding task for task_id: {id}: {e}") raise UserException(code.error, f"Failed to add task: {str(e)}") @@ -197,7 +248,7 @@ def remove_task(project_id: str, task_id: str): """Remove a task from the workforce""" chat_logger.info(f"Removing task {task_id} from workforce for project_id: {project_id}") task_lock = get_task_lock(project_id) - + try: # Queue the remove task action remove_task_action = ActionRemoveTaskData(task_id=task_id, project_id=project_id) @@ -205,7 +256,7 @@ def remove_task(project_id: str, task_id: str): chat_logger.info(f"Task removal request queued for project_id: {project_id}, removing task: {task_id}") return Response(status_code=204) - + except Exception as e: chat_logger.error(f"Error removing task {task_id} for project_id: {project_id}: {e}") raise UserException(code.error, f"Failed to remove task: {str(e)}") @@ -217,7 +268,7 @@ def skip_task(project_id: str): """Skip a task in the workforce""" chat_logger.info(f"Skipping task in workforce for project_id: {project_id}") task_lock = get_task_lock(project_id) - + try: # Queue the skip task action skip_task_action = ActionSkipTaskData(project_id=project_id) @@ -225,7 +276,7 @@ def skip_task(project_id: str): chat_logger.info(f"Task skip request queued for project_id: {project_id}") return Response(status_code=201) - + except Exception as e: chat_logger.error(f"Error skipping task for project_id: {project_id}: {e}") raise UserException(code.error, f"Failed to skip task: {str(e)}") From cbaa477fe8240617a448f177a70f38dcab5b31c2 Mon Sep 17 00:00:00 2001 From: luo <479933015@qq.com> Date: Thu, 6 Nov 2025 12:45:40 +0800 Subject: [PATCH 05/14] fix(chat_controller): change SSE timeout response type from 'timeout' to 'error' --- backend/app/controller/chat_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/controller/chat_controller.py b/backend/app/controller/chat_controller.py index 338537845..7500ea248 100644 --- a/backend/app/controller/chat_controller.py +++ b/backend/app/controller/chat_controller.py @@ -49,7 +49,7 @@ async def timeout_stream_wrapper(stream_generator, timeout_seconds: int = SSE_TI if elapsed >= timeout_seconds: chat_logger.warning(f"SSE timeout: No data received for {elapsed:.1f} seconds, closing connection") - yield sse_json("timeout", {"message": "Connection timeout: No data received for 10 minutes"}) + yield sse_json("error", {"message": "Connection timeout: No data received for 10 minutes"}) break try: data = await asyncio.wait_for(generator.__anext__(), timeout=remaining_timeout) @@ -57,7 +57,7 @@ async def timeout_stream_wrapper(stream_generator, timeout_seconds: int = SSE_TI yield data except asyncio.TimeoutError: chat_logger.warning(f"SSE timeout: No data received for {timeout_seconds} seconds, closing connection") - yield sse_json("timeout", {"message": "Connection timeout: No data received for 10 minutes"}) + yield sse_json("error", {"message": "Connection timeout: No data received for 10 minutes"}) break except StopAsyncIteration: break From 57dca46e9806f5c1f55e6dc9c68f70fc0b84adb7 Mon Sep 17 00:00:00 2001 From: puzhen <1303385763@qq.com> Date: Thu, 6 Nov 2025 10:30:55 +0100 Subject: [PATCH 06/14] fix: add isHumanReply logic to prevent duplicate task boxes Add isHumanReply check from main branch (#577, #602) to prevent duplicate task boxes in human_toolkit multi-turn conversations. This resolves the conflict with main branch by preserving the bug fix while keeping chatbox-ux's UI improvements. --- src/components/ChatBox/UserQueryGroup.tsx | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/ChatBox/UserQueryGroup.tsx b/src/components/ChatBox/UserQueryGroup.tsx index 7f2748d33..4fba6dd31 100644 --- a/src/components/ChatBox/UserQueryGroup.tsx +++ b/src/components/ChatBox/UserQueryGroup.tsx @@ -40,7 +40,25 @@ export const UserQueryGroup: React.FC = ({ // Show task if this query group has a task message OR if it's the most recent user query during splitting // During splitting phase (no to_sub_tasks yet), show task for the most recent query only + // Exclude human-reply scenarios (when user is replying to an activeAsk) + const isHumanReply = queryGroup.userMessage && + activeTaskId && + chatState.tasks[activeTaskId] && + (chatState.tasks[activeTaskId].activeAsk || + // Check if this user message follows an 'ask' message in the message sequence + (() => { + const messages = chatState.tasks[activeTaskId].messages; + const userMessageIndex = messages.findIndex((m: any) => m.id === queryGroup.userMessage.id); + if (userMessageIndex > 0) { + // Check the previous message - if it's an agent message with step 'ask', this is a human-reply + const prevMessage = messages[userMessageIndex - 1]; + return prevMessage?.role === 'agent' && prevMessage?.step === 'ask'; + } + return false; + })()); + const isLastUserQuery = !queryGroup.taskMessage && + !isHumanReply && activeTaskId && chatState.tasks[activeTaskId] && queryGroup.userMessage && @@ -151,7 +169,7 @@ export const UserQueryGroup: React.FC = ({ {/* Sticky Task Box - Show only when task exists and NOT in skeleton phase */} - {task && !isSkeletonPhase && ( + {task && !isSkeletonPhase && !isHumanReply && ( Date: Thu, 6 Nov 2025 10:39:40 +0100 Subject: [PATCH 07/14] update --- utils/__pycache__/__init__.cpython-310.pyc | Bin 150 -> 193 bytes .../traceroot_wrapper.cpython-310.pyc | Bin 2354 -> 2523 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/utils/__pycache__/__init__.cpython-310.pyc b/utils/__pycache__/__init__.cpython-310.pyc index ff3c84b3e30be08256dc9dc392a9678165af8d8a..10d1d4a69c028b0fa2e5b31e6afd01340e2b871d 100644 GIT binary patch literal 193 zcmd1j<>g`kg7uOdnT|mEF^Gc>lA4US1WSq%lT(ZG^Go8(ixLY8Qj3Z}`u#MSZ!v&bx7g$36LWIn<5w~iu>xhl z#4ig`kf}nu!8H_;sF^Gcs diff --git a/utils/__pycache__/traceroot_wrapper.cpython-310.pyc b/utils/__pycache__/traceroot_wrapper.cpython-310.pyc index 8a7166a1508ae29d727cf39e1190149d77a36c7a..d695ee21dc2c1d82d5dfa3d82d7694ce6bef45e3 100644 GIT binary patch delta 693 zcmY+B&ubGw6vyZ7&*^59G@CZgrFqw3Sinl&( zRT6~!jLqes$}FnAH-9o)JTf}wuEi`MhZG6w9h=!`GWwVVNrUjA=d(cKg%+czGkcQ+ z@RXp9^q4S*15fmz?+!>ii96$8q$vhPjs-kf4ZT zIIae{fSt4a|5y_jETf-CZvd>IhC1Pe{v_rHYQ(@d&rKmVI|L`+0GmSnAt_DC8&J}! zE2o>#fQh-Kg<82Q!ZvS4HQw6cK~&q1TAjU`{G}BkEi?KTD9P{o3z(A)_?a)T2<`mR)l}J}^h*C(}ybihsjU;%SB^W=_AF0^qJ(v1?Z= zDk};~4p*z9-sBr$7){p?>a9+_(c#mLefi$L=^rlqD+ZSv zQX~OS9%S&QkoYImKf;@TKm;$Iym|BLtccV%%!m2DdGqD*=4<85-;N7~Jkhat|JUGK zj*#z}I3G-ln|deY?RMwXh!|%`(2T5Oo7+H;p*17i5g>9?nCBN{w1RenpwlAU6}E7v z07S5PVL{}+ewmAk$0c6QawCs>!aoAci^>tj6?A8em#`pKwJKt1X7Z|Vri9l-8Ou}P z^#f5Kt)Yj$sA1(j#nm?y$aoX0Skq0MH>>|=w)WS|oHww%0NxbMc?%m0-CCbu^CR$e zG#-+mr3MguQg8@iH+-B3nS>ah^rK!9-WJKTG@gXFViXs;y+nj!*can8JV}R95~`BX z0Y~i`Utmiy^A&W|Yx47|2ccqpmXODlab z8IJohvz46;n}5<6r(*nEX1}XGJ9nY2yxa}Aq#oq%?OfJLm$=~QrD@bWhdR^+i~6)^ RTFil(&iYo-&9gkX#&56+ef Date: Thu, 6 Nov 2025 11:43:34 +0100 Subject: [PATCH 08/14] fix: add conditional rendering for userMessage to resolve conflict with main Added null check for queryGroup.userMessage to prevent errors when rendering orphan messages (fix #593 from main branch). --- src/components/ChatBox/UserQueryGroup.tsx | 28 ++++++++++++----------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/components/ChatBox/UserQueryGroup.tsx b/src/components/ChatBox/UserQueryGroup.tsx index 4fba6dd31..6bd2ac2f6 100644 --- a/src/components/ChatBox/UserQueryGroup.tsx +++ b/src/components/ChatBox/UserQueryGroup.tsx @@ -154,19 +154,21 @@ export const UserQueryGroup: React.FC = ({ }} className="relative" > - {/* User Query */} - - - + {/* User Query (render only if exists) */} + {queryGroup.userMessage && ( + + + + )} {/* Sticky Task Box - Show only when task exists and NOT in skeleton phase */} {task && !isSkeletonPhase && !isHumanReply && ( From 4424b4d7fcc17baa6ae6524d0d4442870db18aa3 Mon Sep 17 00:00:00 2001 From: Douglasymlai Date: Thu, 6 Nov 2025 11:13:16 +0000 Subject: [PATCH 09/14] update bugs on the user message card --- src/components/ChatBox/UserQueryGroup.tsx | 2 -- utils/__pycache__/__init__.cpython-310.pyc | Bin 193 -> 214 bytes .../traceroot_wrapper.cpython-310.pyc | Bin 2523 -> 2544 bytes 3 files changed, 2 deletions(-) diff --git a/src/components/ChatBox/UserQueryGroup.tsx b/src/components/ChatBox/UserQueryGroup.tsx index 69a4b3d64..7990739b7 100644 --- a/src/components/ChatBox/UserQueryGroup.tsx +++ b/src/components/ChatBox/UserQueryGroup.tsx @@ -164,9 +164,7 @@ export const UserQueryGroup: React.FC = ({ > {}} attaches={queryGroup.userMessage.attaches} /> diff --git a/utils/__pycache__/__init__.cpython-310.pyc b/utils/__pycache__/__init__.cpython-310.pyc index 10d1d4a69c028b0fa2e5b31e6afd01340e2b871d..57ecb1ad7b408569a80294e611f22351efca17cb 100644 GIT binary patch delta 70 zcmX@ec#V-epO=@50SFE^@=WBe(znzPElw>e)=$YVP0vXz)^|xQ&MwI>(09&HNmX!4 YEKb!=%}h_tE7314$;>I%pEzF$0P#E*KL7v# delta 49 zcmcb{c#x4hpO=@50SMMha!ll|l2UQDig7F`%FjwoE{RFaOi#@#i773~%qfnUxJ3y7 DNuv*y diff --git a/utils/__pycache__/traceroot_wrapper.cpython-310.pyc b/utils/__pycache__/traceroot_wrapper.cpython-310.pyc index d695ee21dc2c1d82d5dfa3d82d7694ce6bef45e3..b8d9747f6f81fdae3f1f8ee704ee23bf51b8513c 100644 GIT binary patch delta 73 zcmcaD{6UyIpO=@50SFE^@@(XO#iZ|~A6lGRRIHzpUz(niSgh}oTAW>yU!d=tpOUKJ blvtdqpPHGTnpdJyHFvk=Vy From 03f8b1d246caf7ad0f9b63faa63683e836c7dc90 Mon Sep 17 00:00:00 2001 From: Wendong-Fan Date: Fri, 7 Nov 2025 14:46:27 +0800 Subject: [PATCH 10/14] enhance: add SSE timeout handling and improve logging PR614 --- backend/app/controller/chat_controller.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/app/controller/chat_controller.py b/backend/app/controller/chat_controller.py index 3f0b96ce9..9342b20d6 100644 --- a/backend/app/controller/chat_controller.py +++ b/backend/app/controller/chat_controller.py @@ -38,22 +38,22 @@ SSE_TIMEOUT_SECONDS = 10 * 60 async def timeout_stream_wrapper(stream_generator, timeout_seconds: int = SSE_TIMEOUT_SECONDS): - last_data_time = [time.time()] + """ + Wraps a stream generator with timeout handling. + + Closes the SSE connection if no data is received within the timeout period. + """ + last_data_time = time.time() generator = stream_generator.__aiter__() - should_stop = False try: - while not should_stop: - elapsed = time.time() - last_data_time[0] - remaining_timeout = max(0, timeout_seconds - elapsed) + while True: + elapsed = time.time() - last_data_time + remaining_timeout = timeout_seconds - elapsed - if elapsed >= timeout_seconds: - chat_logger.warning(f"SSE timeout: No data received for {elapsed:.1f} seconds, closing connection") - yield sse_json("error", {"message": "Connection timeout: No data received for 10 minutes"}) - break try: data = await asyncio.wait_for(generator.__anext__(), timeout=remaining_timeout) - last_data_time[0] = time.time() + last_data_time = time.time() yield data except asyncio.TimeoutError: chat_logger.warning(f"SSE timeout: No data received for {timeout_seconds} seconds, closing connection") From d0d15ee66dd7f1f9d3415adb918010361980419e Mon Sep 17 00:00:00 2001 From: Wendong-Fan Date: Sat, 8 Nov 2025 14:35:47 +0800 Subject: [PATCH 11/14] enhance: Reorganize ChatBox components and enhance UX PR567 --- src/components/ChatBox/MessageItem/MarkDown.tsx | 6 +++--- .../ChatBox/MessageItem/UserMessageCard.tsx | 14 ++++++-------- src/components/ChatBox/UserQueryGroup.tsx | 4 ++-- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/components/ChatBox/MessageItem/MarkDown.tsx b/src/components/ChatBox/MessageItem/MarkDown.tsx index 06ed27347..0dfb54d94 100644 --- a/src/components/ChatBox/MessageItem/MarkDown.tsx +++ b/src/components/ChatBox/MessageItem/MarkDown.tsx @@ -23,6 +23,9 @@ export const MarkDown = memo( useEffect(() => { if (!enableTypewriter) { setDisplayedContent(content); + if (onTyping) { + onTyping(); + } return; } @@ -33,9 +36,6 @@ export const MarkDown = memo( if (index < content.length) { setDisplayedContent(content.slice(0, index + 1)); index++; - if (onTyping) { - onTyping(); - } } else { clearInterval(timer); // when typewriter effect is completed, call callback diff --git a/src/components/ChatBox/MessageItem/UserMessageCard.tsx b/src/components/ChatBox/MessageItem/UserMessageCard.tsx index b42cc33b1..c83b83626 100644 --- a/src/components/ChatBox/MessageItem/UserMessageCard.tsx +++ b/src/components/ChatBox/MessageItem/UserMessageCard.tsx @@ -67,12 +67,11 @@ export function UserMessageCard({ return ( <> {visibleFiles.map((file) => { - const isHovered = hoveredFilePath === file.filePath; return (
    setHoveredFilePath(file.filePath)} onMouseLeave={() => setHoveredFilePath((prev) => (prev === file.filePath ? null : prev))} @@ -82,7 +81,7 @@ export function UserMessageCard({ }} > {/* File icon */} -
    +
    {getFileIcon(file.fileName)}
    @@ -98,7 +97,7 @@ export function UserMessageCard({
    ); })} - + {/* Show remaining count if more than 4 files */} {remainingCount > 0 && (
    @@ -118,12 +117,11 @@ export function UserMessageCard({ {isRemainingOpen && (
    - {attaches.slice(maxVisibleFiles, maxVisibleFiles + 5).map((file) => { - const isHovered = hoveredFilePath === file.filePath; + {attaches.slice(maxVisibleFiles).map((file) => { return (
    setHoveredFilePath(file.filePath)} onMouseLeave={() => setHoveredFilePath((prev) => (prev === file.filePath ? null : prev))} onClick={(e) => { @@ -132,7 +130,7 @@ export function UserMessageCard({ setIsRemainingOpen(false); }} > -
    +
    {getFileIcon(file.fileName)}

    diff --git a/src/components/ChatBox/UserQueryGroup.tsx b/src/components/ChatBox/UserQueryGroup.tsx index 7990739b7..71ae69d9a 100644 --- a/src/components/ChatBox/UserQueryGroup.tsx +++ b/src/components/ChatBox/UserQueryGroup.tsx @@ -279,7 +279,7 @@ export const UserQueryGroup: React.FC = ({ } else if (message.content === "skip") { return ( = ({ } else { return ( Date: Mon, 10 Nov 2025 10:19:08 +0100 Subject: [PATCH 12/14] fixed undefined function for file upload & file display popover --- src/components/ChatBox/BottomBox/InputBox.tsx | 133 ++++++++++-------- src/components/ChatBox/BottomBox/index.tsx | 4 +- .../ChatBox/MessageItem/UserMessageCard.tsx | 126 +++++++++-------- 3 files changed, 147 insertions(+), 116 deletions(-) diff --git a/src/components/ChatBox/BottomBox/InputBox.tsx b/src/components/ChatBox/BottomBox/InputBox.tsx index 29a606da5..0a6fefd26 100644 --- a/src/components/ChatBox/BottomBox/InputBox.tsx +++ b/src/components/ChatBox/BottomBox/InputBox.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect } from "react"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Paperclip, ArrowRight, X, Image, FileText, UploadCloud, Plus } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -108,18 +109,25 @@ export const Inputbox = ({ const dragCounter = useRef(0); const [hoveredFilePath, setHoveredFilePath] = useState(null); const [isRemainingOpen, setIsRemainingOpen] = useState(false); - const remainingRef = useRef(null); + const hoverCloseTimerRef = useRef(null); - useEffect(() => { - const onDocClick = (e: MouseEvent) => { - if (!remainingRef.current) return; - if (!remainingRef.current.contains(e.target as Node)) { - setIsRemainingOpen(false); - } - }; - document.addEventListener("mousedown", onDocClick); - return () => document.removeEventListener("mousedown", onDocClick); - }, []); + const openRemainingPopover = () => { + if (hoverCloseTimerRef.current) { + window.clearTimeout(hoverCloseTimerRef.current); + hoverCloseTimerRef.current = null; + } + setIsRemainingOpen(true); + }; + + const scheduleCloseRemainingPopover = () => { + if (hoverCloseTimerRef.current) { + window.clearTimeout(hoverCloseTimerRef.current); + } + hoverCloseTimerRef.current = window.setTimeout(() => { + setIsRemainingOpen(false); + hoverCloseTimerRef.current = null; + }, 150); + }; // Auto-resize textarea on value changes (hug content up to max height) useEffect(() => { @@ -330,57 +338,64 @@ export const Inputbox = ({ })} {/* Show remaining count if more than 5 files */} {remainingCount > 0 && ( -

    - + + -

    - {remainingCount}+ -

    - - {isRemainingOpen && ( -
    -
    - {files.slice(maxVisibleFiles, maxVisibleFiles + 5).map((file) => { - const isHovered = hoveredFilePath === file.filePath; - return ( -
    setHoveredFilePath(file.filePath)} - onMouseLeave={() => setHoveredFilePath((prev) => (prev === file.filePath ? null : prev))} +
    + {files.slice(maxVisibleFiles).map((file) => { + const isHovered = hoveredFilePath === file.filePath; + return ( + - ); - })} -
    + {isHovered ? : getFileIcon(file.fileName)} + +

    + {file.fileName} +

    +
    + ); + })}
    - )} -
    +
    + )}
    )} diff --git a/src/components/ChatBox/BottomBox/index.tsx b/src/components/ChatBox/BottomBox/index.tsx index 6d1275c24..18ae00dc5 100644 --- a/src/components/ChatBox/BottomBox/index.tsx +++ b/src/components/ChatBox/BottomBox/index.tsx @@ -71,7 +71,7 @@ export default function BottomBox({ else if (state === "confirm") backgroundClass = "bg-input-bg-confirm"; return ( -
    +
    {/* QueuedBox overlay (should not affect BoxMain layout) */} {queuedMessages.length > 0 && (
    @@ -82,7 +82,7 @@ export default function BottomBox({
    )} {/* BoxMain */} -
    +
    {/* BoxHeader variants */} {state === "splitting" && ( diff --git a/src/components/ChatBox/MessageItem/UserMessageCard.tsx b/src/components/ChatBox/MessageItem/UserMessageCard.tsx index b42cc33b1..bdbc1eb38 100644 --- a/src/components/ChatBox/MessageItem/UserMessageCard.tsx +++ b/src/components/ChatBox/MessageItem/UserMessageCard.tsx @@ -1,5 +1,6 @@ import { Copy, FileText, X, Image } from "lucide-react"; import { Button } from "../../ui/button"; +import { Popover, PopoverTrigger, PopoverContent } from "../../ui/popover"; import { cn } from "@/lib/utils"; import { useState, useRef, useEffect } from "react"; @@ -17,23 +18,31 @@ export function UserMessageCard({ attaches, }: UserMessageCardProps) { const [hoveredFilePath, setHoveredFilePath] = useState(null); - const [isRemainingOpen, setIsRemainingOpen] = useState(false); - const remainingRef = useRef(null); - - const handleCopy = () => { + const [isRemainingOpen, setIsRemainingOpen] = useState(false); + const hoverCloseTimerRef = useRef(null); + + const handleCopy = () => { navigator.clipboard.writeText(content); }; - useEffect(() => { - const onDocClick = (e: MouseEvent) => { - if (!remainingRef.current) return; - if (!remainingRef.current.contains(e.target as Node)) { - setIsRemainingOpen(false); + // Popover handles outside clicks; no manual listener needed + const openRemainingPopover = () => { + if (hoverCloseTimerRef.current) { + window.clearTimeout(hoverCloseTimerRef.current); + hoverCloseTimerRef.current = null; } + setIsRemainingOpen(true); + }; + + const scheduleCloseRemainingPopover = () => { + if (hoverCloseTimerRef.current) { + window.clearTimeout(hoverCloseTimerRef.current); + } + hoverCloseTimerRef.current = window.setTimeout(() => { + setIsRemainingOpen(false); + hoverCloseTimerRef.current = null; + }, 150); }; - document.addEventListener("mousedown", onDocClick); - return () => document.removeEventListener("mousedown", onDocClick); - }, []); const getFileIcon = (fileName: string) => { const ext = fileName.split(".").pop()?.toLowerCase() || ""; @@ -46,7 +55,7 @@ export function UserMessageCard({ return (
    + + -

    - {remainingCount}+ -

    - - {isRemainingOpen && ( -
    -
    - {attaches.slice(maxVisibleFiles, maxVisibleFiles + 5).map((file) => { - const isHovered = hoveredFilePath === file.filePath; - return ( -
    setHoveredFilePath(file.filePath)} - onMouseLeave={() => setHoveredFilePath((prev) => (prev === file.filePath ? null : prev))} - onClick={(e) => { - e.stopPropagation(); - window.ipcRenderer.invoke("reveal-in-folder", file.filePath); - setIsRemainingOpen(false); - }} - > -
    - {getFileIcon(file.fileName)} -
    -

    - {file.fileName} -

    +
    + {attaches.slice(maxVisibleFiles).map((file) => { + const isHovered = hoveredFilePath === file.filePath; + return ( +
    setHoveredFilePath((prev) => (prev === file.filePath ? null : prev))} + onClick={(e) => { + e.stopPropagation(); + window.ipcRenderer.invoke("reveal-in-folder", file.filePath); + setIsRemainingOpen(false); + }} + > +
    + {getFileIcon(file.fileName)}
    - ); - })} -
    +

    + {file.fileName} +

    +
    + ); + })}
    - )} -
    + + )} ); From 6a6e0ff168a4518c28e3b5ce0ce86e9a1900aef8 Mon Sep 17 00:00:00 2001 From: Douglasymlai Date: Mon, 10 Nov 2025 10:23:59 +0100 Subject: [PATCH 13/14] fix input box Chinese keyboard return problem --- src/components/ChatBox/BottomBox/InputBox.tsx | 61 ++++++++++--------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/src/components/ChatBox/BottomBox/InputBox.tsx b/src/components/ChatBox/BottomBox/InputBox.tsx index 0a6fefd26..c9f332a98 100644 --- a/src/components/ChatBox/BottomBox/InputBox.tsx +++ b/src/components/ChatBox/BottomBox/InputBox.tsx @@ -110,6 +110,7 @@ export const Inputbox = ({ const [hoveredFilePath, setHoveredFilePath] = useState(null); const [isRemainingOpen, setIsRemainingOpen] = useState(false); const hoverCloseTimerRef = useRef(null); + const [isComposing, setIsComposing] = useState(false); const openRemainingPopover = () => { if (hoverCloseTimerRef.current) { @@ -156,7 +157,7 @@ export const Inputbox = ({ }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey && !disabled) { + if (e.key === "Enter" && !e.shiftKey && !disabled && !isComposing) { e.preventDefault(); handleSend(); } @@ -259,34 +260,36 @@ export const Inputbox = ({ {/* Text Input Area */}
    -