Merge branch 'main' into 621-bug-react-warning-confirmbuttondisabled-prop-passed-to-dom-element

This commit is contained in:
Wendong-Fan 2025-11-13 20:08:28 +08:00
commit d47bb9f165
30 changed files with 653 additions and 333 deletions

View file

@ -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()
# 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):
"""
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__()
try:
while True:
elapsed = time.time() - last_data_time
remaining_timeout = timeout_seconds - elapsed
try:
data = await asyncio.wait_for(generator.__anext__(), timeout=remaining_timeout)
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")
yield sse_json("error", {"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
@ -57,7 +96,14 @@ async def post(data: Chat, request: Request):
chat_logger.info(f"Set search config: {key}", extra={"project_id": data.project_id})
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)
@ -68,8 +114,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")
@ -84,14 +135,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
@ -100,7 +151,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:
@ -109,7 +160,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}
@ -117,12 +168,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}")
@ -167,7 +218,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})
@ -180,7 +231,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(
@ -188,11 +239,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)}")
@ -204,7 +255,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)
@ -212,7 +263,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)}")
@ -224,7 +275,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)
@ -232,7 +283,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)}")

View file

@ -1061,7 +1061,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'),

View file

@ -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,26 @@ export const Inputbox = ({
const dragCounter = useRef(0);
const [hoveredFilePath, setHoveredFilePath] = useState<string | null>(null);
const [isRemainingOpen, setIsRemainingOpen] = useState(false);
const remainingRef = useRef<HTMLDivElement | null>(null);
const hoverCloseTimerRef = useRef<number | null>(null);
const [isComposing, setIsComposing] = useState(false);
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(() => {
@ -148,7 +157,7 @@ export const Inputbox = ({
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !disabled) {
if (e.key === "Enter" && !e.shiftKey && !disabled && !isComposing) {
e.preventDefault();
handleSend();
}
@ -251,34 +260,36 @@ export const Inputbox = ({
{/* Text Input Area */}
<div className="box-border flex gap-2.5 items-center justify-center pb-2 pt-2.5 px-0 relative w-full">
<div className="flex-1 box-border flex gap-2.5 items-center justify-center min-h-px min-w-px mx-2 py-0 relative">
<Textarea
variant="none"
size="default"
ref={textareaRef}
value={value}
onChange={(e) => handleTextChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={disabled}
placeholder= {t("chat.ask-placeholder")}
className={cn(
"flex-1 resize-none",
"border-none shadow-none focus-visible:ring-0 focus-visible:outline-none",
"px-0 py-0 min-h-[40px] max-h-[200px]",
"scrollbar overflow-auto",
isActive ? "text-input-text-focus" : "text-input-text-default"
)}
style={{
fontFamily: "Inter",
}}
rows={1}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
}}
/>
<Textarea
variant="none"
size="default"
ref={textareaRef}
value={value}
onChange={(e) => handleTextChange(e.target.value)}
onKeyDown={handleKeyDown}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={disabled}
placeholder= {t("chat.ask-placeholder")}
className={cn(
"flex-1 resize-none",
"border-none shadow-none focus-visible:ring-0 focus-visible:outline-none",
"px-0 py-0 min-h-[40px] max-h-[200px]",
"scrollbar overflow-auto",
isActive ? "text-input-text-focus" : "text-input-text-default"
)}
style={{
fontFamily: "Inter",
}}
rows={1}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
}}
/>
</div>
</div>
@ -330,57 +341,64 @@ export const Inputbox = ({
})}
{/* Show remaining count if more than 5 files */}
{remainingCount > 0 && (
<div ref={remainingRef} className="relative">
<Button
size="icon"
variant="ghost"
className="bg-tag-surface box-border flex items-center relative rounded-lg h-auto"
onClick={(e) => {
e.stopPropagation();
setIsRemainingOpen((v) => !v);
}}
<Popover open={isRemainingOpen} onOpenChange={setIsRemainingOpen}>
<PopoverTrigger asChild>
<Button
size="icon"
variant="ghost"
className="bg-tag-surface box-border flex items-center relative rounded-lg h-auto"
onMouseEnter={openRemainingPopover}
onMouseLeave={scheduleCloseRemainingPopover}
onClick={(e) => {
e.stopPropagation();
}}
>
<p className="font-['Inter'] font-bold leading-tight text-text-body text-xs whitespace-nowrap my-0">
{remainingCount}+
</p>
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={4}
className="!w-auto max-w-40 p-1 rounded-md border border-dropdown-border bg-dropdown-bg shadow-perfect"
onMouseEnter={openRemainingPopover}
onMouseLeave={scheduleCloseRemainingPopover}
>
<p className="font-['Inter'] font-bold leading-tight text-text-body text-xs whitespace-nowrap my-0">
{remainingCount}+
</p>
</Button>
{isRemainingOpen && (
<div className="absolute left-0 mt-1 z-30 max-w-40 p-1 rounded-md border border-dropdown-border bg-dropdown-bg shadow-perfect">
<div className="max-h-64 overflow-auto gap-1 flex flex-col">
{files.slice(maxVisibleFiles, maxVisibleFiles + 5).map((file) => {
const isHovered = hoveredFilePath === file.filePath;
return (
<div
key={file.filePath}
className="flex items-center gap-1 px-1 py-0.5 bg-tag-surface rounded-md"
onMouseEnter={() => setHoveredFilePath(file.filePath)}
onMouseLeave={() => setHoveredFilePath((prev) => (prev === file.filePath ? null : prev))}
<div className="max-h-[176px] overflow-auto scrollbar-hide gap-1 flex flex-col">
{files.slice(maxVisibleFiles).map((file) => {
const isHovered = hoveredFilePath === file.filePath;
return (
<div
key={file.filePath}
className="flex items-center gap-1 px-1 py-0.5 bg-tag-surface hover:bg-tag-surface-hover transition-colors duration-300 cursor-pointer rounded-lg"
onMouseEnter={() => setHoveredFilePath(file.filePath)}
onMouseLeave={() => setHoveredFilePath((prev) => (prev === file.filePath ? null : prev))}
>
<a
href="#"
className={cn(
"rounded-md cursor-pointer flex items-center justify-center w-6 h-6"
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveFile(file.filePath);
setIsRemainingOpen(false);
}}
title={isHovered ? "Remove file" : file.fileName}
>
<a
href="#"
className={cn(
"rounded-md cursor-pointer flex items-center justify-center w-6 h-6"
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveFile(file.filePath);
setIsRemainingOpen(false);
}}
title={isHovered ? "Remove file" : file.fileName}
>
{isHovered ? <X className="text-icon-secondary size-4" /> : getFileIcon(file.fileName)}
</a>
<p className="flex-1 font-['Inter'] font-bold leading-tight text-text-body text-xs whitespace-nowrap my-0 overflow-hidden text-ellipsis">
{file.fileName}
</p>
</div>
);
})}
</div>
{isHovered ? <X className="text-icon-secondary size-4" /> : getFileIcon(file.fileName)}
</a>
<p className="flex-1 font-['Inter'] font-bold leading-tight text-text-body text-xs whitespace-nowrap my-0 overflow-hidden text-ellipsis">
{file.fileName}
</p>
</div>
);
})}
</div>
)}
</div>
</PopoverContent>
</Popover>
)}
</div>
)}

View file

@ -71,7 +71,7 @@ export default function BottomBox({
else if (state === "confirm") backgroundClass = "bg-input-bg-confirm";
return (
<div className="flex flex-col w-full overflow-hidden relative z-50">
<div className="flex flex-col w-full relative z-50">
{/* QueuedBox overlay (should not affect BoxMain layout) */}
{queuedMessages.length > 0 && (
<div className="px-2 z-50 pointer-events-auto">
@ -82,7 +82,7 @@ export default function BottomBox({
</div>
)}
{/* BoxMain */}
<div className={`flex flex-col gap-2 w-full p-2 rounded-lg ${backgroundClass} overflow-hidden`}>
<div className={`flex flex-col gap-2 w-full p-2 rounded-t-lg ${backgroundClass}`}>
{/* BoxHeader variants */}
{state === "splitting" && (
<BoxHeaderSplitting />

View file

@ -33,7 +33,7 @@ export const FloatingAction = ({
return (
<div
className={cn(
"sticky top-2 bottom-2 left-0 right-0 flex w-full justify-center items-center z-20 pointer-events-none",
"sticky top-2 bottom-2 mt-4 left-0 right-0 flex w-full justify-center items-center z-20 pointer-events-none",
className
)}
>

View file

@ -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<string, boolean>();
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 (
<div
key={id}
className={`relative ${
role === "agent" ? "bg-white-0% py-0" : "bg-white-80%"
} w-full rounded-xl border px-4 py-3 ${className || ""} group`}
className={`relative bg-white-0% w-full rounded-xl border px-sm py-3 ${className || ""} group overflow-hidden`}
>
{role === "user" && (
<div className="absolute bottom-[0px] right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<Button onClick={handleCopy} variant="ghost" size="icon">
<Copy />
</Button>
</div>
)}
<div className="absolute bottom-[0px] right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<Button onClick={handleCopy} variant="ghost" size="icon">
<Copy />
</Button>
</div>
<MarkDown
content={content}
onTyping={handleTypingComplete}
@ -79,11 +73,11 @@ export function MessageCard({
window.ipcRenderer.invoke("reveal-in-folder", file.filePath);
}}
key={"attache-" + file.fileName}
className="cursor-pointer flex items-center gap-2 bg-message-fill-default border border-solid border-task-border-default rounded-[12px] px-2 py-1 w-[140px] "
className="cursor-pointer flex w-full items-center gap-2 bg-message-fill-default border border-solid border-task-border-default rounded-2xl pl-2 py-1 "
>
<FileText size={24} className="flex-shrink-0" />
<div className="flex flex-col">
<div className="max-w-[100px] font-bold text-sm text-body text-text-body overflow-hidden text-ellipsis whitespace-nowrap">
<div className="max-w-48 font-bold text-sm text-body text-text-body overflow-hidden text-ellipsis whitespace-nowrap">
{file?.fileName?.split(".")[0]}
</div>
<div className="font-medium leading-29 text-xs text-text-body">
@ -98,3 +92,4 @@ export function MessageCard({
</div>
);
}

View file

@ -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 (
<div
key={id}
className={`bg-message-fill-secondary w-full rounded-xl border px-4 py-3 flex flex-col gap-4 items-center justify-center relative group overflow-hidden ${className || ""}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Copy button - appears on hover */}
<div className="absolute bottom-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<Button onClick={handleCopy} variant="ghost" size="icon">
<Copy className="w-4 h-4" />
</Button>
</div>
{/* Title */}
<p className="font-inter font-bold leading-normal text-sm text-text-body w-full">
{title}
</p>
{/* Content */}
<p className="font-inter font-medium leading-normal text-sm text-text-body w-full">
{content}
</p>
{/* Action buttons */}
<div className="flex gap-1 items-center w-full">
<Button
onClick={onConfirm}
variant="primary"
size="xs"
className="flex-1"
>
Answer Agent
</Button>
<Button
onClick={onSkip}
variant="ghost"
size="xs"
className="flex-1"
>
Skip
</Button>
</div>
</div>
);
}

View file

@ -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;
@ -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
@ -49,7 +49,7 @@ export const MarkDown = memo(
}, [content, speed, enableTypewriter, onTyping]);
return (
<div className="max-w-none">
<div className="max-w-none markdown-container overflow-hidden">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
@ -70,36 +70,35 @@ export const MarkDown = memo(
),
p: ({ children }) => (
<p
className={`${pTextSize} font-medium text-primary leading-10 font-inter whitespace-pre-wrap break-words text-wrap`}
style={{ margin: 0 }}
className={`${pTextSize} font-medium text-text-body leading-10 font-inter whitespace-pre-wrap break-all`}
style={{ margin: 0, wordBreak: 'break-all' }}
>
{children}
</p>
),
ul: ({ children }) => (
<ul
className={`list-disc list-inside text-[13px] text-primary mb-2 ${olPadding}`}
className={`list-disc list-outside text-body-sm text-text-body ml-3 mb-2 ${olPadding}`}
>
{children}
</ul>
),
ol: ({ children }) => (
<ol
className={`list-decimal list-inside text-[13px] text-primary mb-2 ${olPadding}`}
>
{children}
</ol>
),
li: ({ children }) => (
<li className="mb-1 list-inside">{children}</li>
<li className="my-sm text-body-sm text-text-body">{children}</li>
),
code: ({ children }) => (
<code className="bg-zinc-100 px-1 py-0.5 rounded text-xs font-mono">
<code
className="bg-zinc-100 px-1 py-0.5 rounded text-body-sm text-text-body font-mono whitespace-pre-wrap break-all"
style={{ wordBreak: 'break-all' }}
>
{children}
</code>
),
pre: ({ children }) => (
<pre className="bg-zinc-100 p-2 rounded text-xs overflow-x-auto">
<pre
className="bg-zinc-100 p-2 rounded text-xs overflow-x-auto whitespace-pre-wrap break-all"
style={{ wordBreak: 'break-all' }}
>
{children}
</pre>
),
@ -117,7 +116,8 @@ export const MarkDown = memo(
a: ({ children, href }) => (
<a
href={href}
className=" hover:text-blue-800 underline break-all"
className="hover:text-blue-800 underline break-all"
style={{ wordBreak: 'break-all' }}
target="_blank"
rel="noopener noreferrer"
>

View file

@ -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";

View file

@ -0,0 +1,172 @@
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";
interface UserMessageCardProps {
id: string;
content: string;
className?: string;
attaches?: File[];
}
export function UserMessageCard({
id,
content,
className,
attaches,
}: UserMessageCardProps) {
const [hoveredFilePath, setHoveredFilePath] = useState<string | null>(null);
const [isRemainingOpen, setIsRemainingOpen] = useState(false);
const hoverCloseTimerRef = useRef<number | null>(null);
const handleCopy = () => {
navigator.clipboard.writeText(content);
};
// 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);
};
const getFileIcon = (fileName: string) => {
const ext = fileName.split(".").pop()?.toLowerCase() || "";
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
return <Image className="w-4 h-4 text-icon-primary" />;
}
return <FileText className="w-4 h-4 text-icon-primary" />;
};
return (
<div
key={id}
className={`relative bg-white-80% w-full rounded-xl border px-sm py-2 ${className || ""} group overflow-visible`}
>
<div className="absolute bottom-[0px] right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<Button onClick={handleCopy} variant="ghost" size="icon">
<Copy />
</Button>
</div>
<div className="text-text-body text-body-sm whitespace-pre-wrap break-words">
{content}
</div>
{attaches && attaches.length > 0 && (
<div className="box-border flex flex-wrap gap-1 items-start relative w-full mt-2">
{(() => {
// 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 (
<div
key={"attache-" + file.fileName}
className={cn(
"bg-tag-surface box-border flex gap-0.5 items-center relative rounded-lg max-w-32 h-auto cursor-pointer hover:bg-tag-surface-hover transition-colors duration-300"
)}
onMouseEnter={() => 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 */}
<div className="rounded-md flex items-center justify-center w-6 h-6">
{getFileIcon(file.fileName)}
</div>
{/* File Name */}
<p
className={cn(
"flex-1 font-['Inter'] font-bold leading-tight min-h-px min-w-px overflow-ellipsis overflow-hidden relative text-text-body text-xs whitespace-nowrap my-0"
)}
title={file.fileName}
>
{file.fileName}
</p>
</div>
);
})}
{/* Show remaining count if more than 4 files */}
{remainingCount > 0 && (
<Popover open={isRemainingOpen} onOpenChange={setIsRemainingOpen}>
<PopoverTrigger asChild>
<Button
size="icon"
variant="ghost"
className="bg-tag-surface box-border flex items-center relative rounded-lg h-auto"
onMouseEnter={openRemainingPopover}
onMouseLeave={scheduleCloseRemainingPopover}
onClick={(e) => {
e.stopPropagation();
}}
>
<p className="font-['Inter'] font-bold leading-tight text-text-body text-xs whitespace-nowrap my-0">
{remainingCount}+
</p>
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={4}
className="!w-auto max-w-40 p-1 rounded-md border border-dropdown-border bg-dropdown-bg shadow-perfect"
onMouseEnter={openRemainingPopover}
onMouseLeave={scheduleCloseRemainingPopover}
>
<div className="max-h-[176px] overflow-auto scrollbar-hide gap-1 flex flex-col">
{attaches.slice(maxVisibleFiles).map((file) => {
const isHovered = hoveredFilePath === file.filePath;
return (
<div
key={file.filePath}
className="flex items-center gap-1 py-0.5 bg-tag-surface hover:bg-tag-surface-hover transition-colors duration-300 cursor-pointer rounded-lg"
onMouseLeave={() => setHoveredFilePath((prev) => (prev === file.filePath ? null : prev))}
onClick={(e) => {
e.stopPropagation();
window.ipcRenderer.invoke("reveal-in-folder", file.filePath);
setIsRemainingOpen(false);
}}
>
<div className="rounded-md flex items-center justify-center w-6 h-6">
{getFileIcon(file.fileName)}
</div>
<p className="flex-1 font-['Inter'] font-bold leading-tight text-text-body text-xs whitespace-nowrap my-0 overflow-hidden text-ellipsis">
{file.fileName}
</p>
</div>
);
})}
</div>
</PopoverContent>
</Popover>
)}
</>
);
})()}
</div>
)}
</div>
);
}

View file

@ -137,7 +137,7 @@ export const ProjectChatContainer: React.FC<ProjectChatContainerProps> = ({
return (
<div
ref={containerRef}
className={`flex-1 relative z-10 flex flex-col overflow-y-auto scrollbar gap-2 ${className}`}
className={`flex-1 relative z-10 flex flex-col mt-sm overflow-y-auto scrollbar ${className}`}
>
<AnimatePresence mode="popLayout">
{chatStores.map(({ chatId, chatStore }) => {
@ -149,9 +149,12 @@ export const ProjectChatContainer: React.FC<ProjectChatContainerProps> = ({
}
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;
}

View file

@ -43,7 +43,7 @@ export const ProjectSection = React.forwardRef<HTMLDivElement, ProjectSectionPro
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="relative"
className="relative mb-8"
>
{/* User Query Groups */}
<div className="space-y-0">

View file

@ -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 {
@ -173,16 +173,16 @@ export function TaskCard({
return (
<div>
<div className="w-full h-auto flex flex-col gap-2 transition-all duration-300">
<div className="w-full h-auto bg-task-surface backdrop-blur-[5px] rounded-xl py-sm relative overflow-hidden">
<div className="w-full h-auto flex flex-col pl-sm gap-2 transition-all duration-300">
<div className="w-full h-auto bg-task-surface rounded-xl py-sm relative overflow-hidden">
<div className="absolute top-0 left-0 w-full bg-transparent">
<Progress value={progressValue} className="h-[2px] w-full" />
</div>
<div className="text-sm font-bold leading-13 mb-2.5 px-sm">
{summaryTask
? summaryTask.split("|")[0].replace(/"/g, "")
: "Thinking hard..."}
</div>
{summaryTask && (
<div className="text-sm font-bold leading-13 mb-2.5 px-sm">
{summaryTask.split("|")[0].replace(/"/g, "")}
</div>
)}
{summaryTask && (
<div className={`flex items-center justify-between gap-2 px-sm`}>
@ -422,7 +422,7 @@ export function TaskCard({
</div>
<div className="flex-1 flex flex-col items-start justify-center">
<div
className={` w-full break-words [overflow-wrap:anywhere] whitespace-pre-line ${
className={` w-full break-words whitespace-pre-line ${
task.status === "failed"
? "text-text-cuation-default"
: task.status === "blocked"

View file

@ -9,7 +9,7 @@ import {
X,
} from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { Button } from "../ui/button";
import { Button } from "../../ui/button";
import { useTranslation } from "react-i18next";
interface TaskItemProps {

View file

@ -12,7 +12,7 @@ export const TypeCardSkeleton = ({
const { t } = useTranslation();
return (
<div>
<div className="w-full h-auto flex flex-col gap-2 px-2 py-sm transition-all duration-300 ">
<div className="w-full h-auto flex flex-col gap-2 pl-2 py-sm transition-all duration-300 ">
<div className="w-full h-auto bg-task-surface backdrop-blur-[5px] rounded-xl py-sm relative overflow-hidden">
<div className="absolute top-0 left-0 w-full bg-transparent">
<Progress value={100} className="h-[2px] w-full" />

View file

@ -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;
@ -54,7 +56,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
}
return false;
})());
const isLastUserQuery = !queryGroup.taskMessage &&
!isHumanReply &&
activeTaskId &&
@ -131,10 +133,13 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
}, [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 (
@ -155,20 +160,18 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="px-2 py-sm"
className="pl-sm py-sm"
>
<MessageCard
<UserMessageCard
id={queryGroup.userMessage.id}
role={queryGroup.userMessage.role}
content={queryGroup.userMessage.content}
onTyping={() => {}}
attaches={queryGroup.userMessage.attaches}
/>
</motion.div>
)}
{/* 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 && !isHumanReply && (
<motion.div
ref={taskBoxRef}
className="sticky top-0 z-20"
@ -182,19 +185,11 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
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
}}
>
<div
@ -241,21 +236,20 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
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"
>
<MessageCard
<AgentMessageCard
typewriter={
task?.type !== "replay" ||
(task?.type === "replay" && task?.delayTime !== 0)
}
id={message.id}
role={message.role}
content={message.content}
onTyping={() => {}}
/>
{/* File List */}
{message.fileList && (
<div className="flex gap-2 flex-wrap">
<div className="flex pl-3 gap-2 flex-wrap">
{message.fileList.map((file: any) => (
<motion.div
key={`file-${file.name}`}
@ -284,28 +278,42 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
);
} else if (message.content === "skip") {
return (
<MessageCard
<motion.div
key={`skip-${message.id}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex flex-col pl-3 gap-4"
>
<AgentMessageCard
key={message.id}
id={message.id}
role={message.role}
content="No reply received, task continues..."
onTyping={() => {}}
/>
</motion.div>
);
} else {
return (
<MessageCard
<motion.div
key={`message-${message.id}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex flex-col pl-3 gap-4"
>
<AgentMessageCard
key={message.id}
typewriter={
task?.type !== "replay" ||
(task?.type === "replay" && task?.delayTime !== 0)
}
id={message.id}
role={message.role}
content={message.content}
onTyping={() => {}}
attaches={message.attaches}
/>
</motion.div>
);
}
} else if (message.step === "end" && message.content === "") {
@ -315,7 +323,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
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 && (
<div className="flex gap-2 flex-wrap">
@ -329,10 +337,11 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
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"
>
<FileText size={16} className="text-icon-primary flex-shrink-0" />
<div className="flex flex-col">
<div className="max-w-[100px] font-bold text-sm text-body text-text-body overflow-hidden text-ellipsis whitespace-nowrap">
<div className="max-w-48 font-bold text-sm text-body text-text-body overflow-hidden text-ellipsis whitespace-nowrap">
{file.name.split(".")[0]}
</div>
<div className="font-medium leading-29 text-xs text-text-body">

View file

@ -484,9 +484,16 @@ export default function ChatBox(): JSX.Element {
const messageIndex = chatStore.tasks[taskId].messages.findLastIndex(
(item) => item.step === "to_sub_tasks"
);
const question = chatStore.tasks[taskId].messages[messageIndex - 2].content;
const questionMessage = chatStore.tasks[taskId].messages[messageIndex - 2];
const question = questionMessage.content;
// Get the file attachments from the original user message (not from task.attaches which gets cleared after sending)
const attachments = questionMessage.attaches || [];
let id = chatStore.create();
chatStore.setHasMessages(id, true);
// Copy the file attachments to the new task
if (attachments.length > 0) {
chatStore.setAttaches(id, attachments);
}
chatStore.removeTask(taskId);
proxyFetchDelete(`/api/chat/history/${taskId}`);
setMessage(question);
@ -645,10 +652,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
@ -662,11 +670,9 @@ export default function ChatBox(): JSX.Element {
}, [chatStore, getAllChatStoresMemoized]);
return (
<div className="w-full h-full flex flex-col items-center justify-center">
<div className="w-full h-full flex-none items-center justify-center">
{hasAnyMessages ? (
<div className="w-full h-[calc(100vh-54px)] flex flex-col rounded-xl border border-border-disabled border-solid relative shadow-blur-effect overflow-hidden">
<div className="absolute inset-0 blur-bg bg-bg-surface-secondary pointer-events-none"></div>
<div className="w-full h-full flex-1 flex flex-col">
{/* New Project Chat Container */}
<ProjectChatContainer
onPauseResume={handlePauseResume}
@ -723,8 +729,8 @@ export default function ChatBox(): JSX.Element {
</div>
) : (
// Init ChatBox
<div className="w-full h-[calc(100vh-54px)] flex items-center rounded-xl border border-border-disabled py-2 border-solid relative overflow-hidden">
<div className="absolute inset-0 blur-bg bg-bg-surface-secondary pointer-events-none"></div>
<div className="w-full h-[calc(100vh-54px)] flex items-center py-2 relative overflow-hidden">
<div className="absolute inset-0 pointer-events-none"></div>
<div className=" w-full flex flex-col relative z-10">
<div className="flex flex-col items-center gap-1 h-[210px] justify-end">
<div className="text-body-lg text-text-heading text-center font-bold">

View file

@ -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";

View file

@ -330,7 +330,7 @@ export default function HistorySidebar() {
/>
</div>
<div className="text-left text-[14px] text-text-primary font-bold leading-9 overflow-hidden text-ellipsis break-words line-clamp-3">
{task?.messages[0]?.content || t("layout.new-project")}
{task?.messages?.[0]?.content || t("layout.new-project")}
</div>
<div className="w-full">
<Progress
@ -430,13 +430,13 @@ export default function HistorySidebar() {
<TooltipSimple
content={
<p>
{task?.messages[0]?.content || t("layout.new-project")}
{task?.messages?.[0]?.content || t("layout.new-project")}
</p>
}
className="w-[300px] bg-surface-tertiary p-2 text-wrap break-words text-label-xs select-text pointer-events-auto shadow-perfect"
>
<span>
{task?.messages[0]?.content || t("dashboard.new-project")}
{task?.messages?.[0]?.content || t("dashboard.new-project")}
</span>
</TooltipSimple>
</div>

View file

@ -14,10 +14,17 @@ import {
ChevronLeft,
LayoutGrid,
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;
@ -179,9 +188,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 with replace to force refresh
navigate("/", { replace: true });
@ -240,7 +249,6 @@ function HeaderWin() {
onClick={() => navigate("/")}
>
<ChevronLeft className="w-4 h-4" />
{t("layout.back")}
</Button>
</div>
)}
@ -304,60 +312,6 @@ function HeaderWin() {
platform === "darwin" && "pr-2"
} flex h-full items-center z-50 relative no-drag gap-1`}
>
{chatStore.activeTaskId && chatStore.tasks[chatStore.activeTaskId as string] && (
<>
<TooltipSimple content={t("layout.report-bug")} side="bottom" align="center">
<Button
onClick={exportLog}
variant="ghost"
size="xs"
className="no-drag"
>
<FileDown className="w-4 h-4" />
{t("layout.report-bug")}
</Button>
</TooltipSimple>
</>
)}
<TooltipSimple content={t("layout.refer-friends")} side="bottom" align="center">
<Button
onClick={getReferFriendsLink}
variant="ghost"
size="xs"
className="no-drag"
>
<img
src={giftIcon}
alt="gift-icon"
className="w-4 h-4"
/>
{t("layout.refer-friends")}
</Button>
</TooltipSimple>
<TooltipSimple content={t("layout.settings")} side="bottom" align="center">
<Button
onClick={() => navigate("/history?tab=settings")}
variant="ghost"
size="xs"
className="no-drag"
>
<Settings className="w-4 h-4" />
{t("layout.settings")}
</Button>
</TooltipSimple>
{chatStore.activeTaskId &&
chatStore.tasks[chatStore.activeTaskId as string]?.status === 'finished' && (
<TooltipSimple content={t("layout.share")} side="bottom" align="end">
<Button
onClick={() => handleShare(chatStore.activeTaskId as string)}
variant="primary"
size="xs"
className="no-drag !text-button-fill-information-foreground"
>
{t("layout.share")}
</Button>
</TooltipSimple>
)}
{chatStore.activeTaskId &&
chatStore.tasks[chatStore.activeTaskId as string] &&
(
@ -377,6 +331,50 @@ function HeaderWin() {
</Button>
</TooltipSimple>
)}
{chatStore.activeTaskId &&
chatStore.tasks[chatStore.activeTaskId as string]?.status === 'finished' && (
<TooltipSimple content={t("layout.share")} side="bottom" align="end">
<Button
onClick={() => handleShare(chatStore.activeTaskId as string)}
variant="ghost"
size="xs"
className="no-drag !text-button-fill-information-foreground bg-button-fill-information"
>
{t("layout.share")}
</Button>
</TooltipSimple>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="no-drag"
>
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
{chatStore.activeTaskId && chatStore.tasks[chatStore.activeTaskId as string] && (
<DropdownMenuItem onClick={exportLog} className="cursor-pointer">
<FileDown className="w-4 h-4" />
{t("layout.report-bug")}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={getReferFriendsLink} className="cursor-pointer">
<img
src={giftIcon}
alt="gift-icon"
className="w-4 h-4"
/>
{t("layout.refer-friends")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/history?tab=settings")} className="cursor-pointer">
<Settings className="w-4 h-4" />
{t("layout.settings")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{location.pathname === "/history" && (

View file

@ -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 (
<div className="prose prose-sm w-full select-text pointer-events-auto overflow-x-auto">
<div className="prose prose-sm w-full select-text pointer-events-auto overflow-x-auto markdown-container">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
@ -81,13 +81,13 @@ export const MarkDown = ({
{children}
</ul>
),
ol: ({ children }) => (
<ol
className={`list-decimal list-inside text-xs text-primary mb-1 ${olPadding}`}
>
{children}
</ol>
),
// ol: ({ children }) => (
// <ol
// className={`list-decimal list-inside text-xs text-primary mb-1 ${olPadding}`}
// >
// {children}
// </ol>
// ),
li: ({ children }) => (
<li className="mb-1 list-inside break-all">{children}</li>
),

View file

@ -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}
/>
</DropdownMenuPrimitive.Portal>
@ -82,7 +87,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>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
)}

View file

@ -393,11 +393,11 @@ export default function Project() {
</div>
<div
className={`px-3 h-full flex gap-sm border-[0px] border-solid ${
task.taskAssigning.length > 0 &&
task.taskAssigning && task.taskAssigning.length > 0 &&
"border-x border-white-100%"
}`}
>
{task.taskAssigning.map((taskAssigning) => (
{task.taskAssigning && task.taskAssigning.map((taskAssigning) => (
<div
key={taskAssigning.agent_id}
aria-label="Toggle bold"

View file

@ -77,7 +77,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 (
<div ref={scrollContainerRef} className="h-full overflow-y-auto scrollbar-hide mx-auto">

View file

@ -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 (
<div className="h-full min-h-0 flex flex-row overflow-hidden pt-8">
<div className="h-full min-h-0 flex flex-row overflow-hidden pt-10 px-2 pb-2">
<ReactFlowProvider>
<div className="flex-1 min-w-0 min-h-0 flex items-center justify-center gap-2 relative overflow-hidden">
<div className="flex-1 min-w-0 min-h-0 flex items-center justify-center bg-surface-secondary border-solid border-border-tertiary rounded-2xl gap-2 relative overflow-hidden">
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={30} minSize={20}>
<div className="flex-1 h-full min-w-0 min-h-0 flex items-center justify-center py-2 pl-2 overflow-hidden">
<ChatBox />
</div>
</ResizablePanel>
<ResizableHandle withHandle={true} className="custom-resizable-handle" />
<ResizablePanel>

View file

@ -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 = () => (
<Route element={<Layout />}>
<Route path="/" element={<Home />} />
<Route path="/history" element={<History />} />
<Route path="/setting" element={<Setting />}>
{/* Setting sub-routes */}
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<SettingGeneral />} />
<Route path="privacy" element={<SettingPrivacy />} />
<Route path="models" element={<SettingModels />} />
<Route path="api" element={<SettingAPI />} />
<Route path="mcp" element={<SettingMCP />} />
<Route path="mcp_market" element={<MCPMarket />} />
</Route>
<Route path="/setting" element={<Navigate to="/history?tab=settings" replace />} />
<Route path="/setting/*" element={<Navigate to="/history?tab=settings" replace />} />
</Route>
</Route>
<Route path="*" element={<NotFound />} />

View file

@ -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 {
@ -416,4 +414,9 @@ code {
.stack-login-btn,
.stack-login-btn button {
width: 100%;
}
.markdown-container ol {
padding-left: 1rem;
font-size: 12px; /* text-sm */
}

View file

@ -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)",