mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-23 04:17:45 +00:00
refactor spliting task ux and ui (#1620)
This commit is contained in:
parent
9d06ad0b0e
commit
29ecdb6267
29 changed files with 1364 additions and 749 deletions
|
|
@ -12,55 +12,11 @@
|
|||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { UserMessageRichContent } from '@/components/ChatBox/MessageItem/UserMessageRichContent';
|
||||
import { AnimateIcon } from '@/components/ui/animate-ui/icons/icon';
|
||||
import { Orbit } from '@/components/ui/animate-ui/icons/orbit';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Variant: Splitting
|
||||
*/
|
||||
export interface BoxHeaderSplittingProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const BoxHeaderSplitting = ({ className }: BoxHeaderSplittingProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-2 gap-1 flex w-full flex-col items-start justify-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="gap-1 px-2.5 pt-2 relative box-border flex w-full items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="px-1 focus:ring-0 focus-visible:outline-none"
|
||||
>
|
||||
<AnimateIcon
|
||||
animate
|
||||
loop
|
||||
className="h-4 w-4 !text-ds-text-information-default-default items-center justify-center"
|
||||
>
|
||||
<Orbit size={16} />
|
||||
</AnimateIcon>
|
||||
</Button>
|
||||
|
||||
<div className="gap-0.5 relative flex min-h-px min-w-px flex-1 items-center">
|
||||
<span className="text-body-sm font-bold text-ds-text-information-default-default whitespace-nowrap">
|
||||
{t('chat.splitting-tasks')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Variant: Confirm
|
||||
*/
|
||||
|
|
@ -83,11 +39,11 @@ export const BoxHeaderConfirm = ({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-2 gap-1 flex w-full flex-col items-start justify-center',
|
||||
'mb-2 gap-1 flex w-full flex-col items-start justify-between',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="gap-1 px-2.5 pt-2 relative box-border flex w-full items-center">
|
||||
<div className="gap-1 px-2.5 pt-2 relative box-border flex w-full items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -100,18 +56,6 @@ export const BoxHeaderConfirm = ({
|
|||
<ChevronLeft />
|
||||
</Button>
|
||||
|
||||
<div className="gap-0.5 relative flex min-h-px min-w-px flex-1 items-center">
|
||||
{subtitle ? (
|
||||
<div className="relative flex min-h-px min-w-px flex-1 flex-col justify-center overflow-hidden">
|
||||
<UserMessageRichContent
|
||||
content={subtitle}
|
||||
variant="compact"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="success"
|
||||
size="sm"
|
||||
|
|
@ -125,3 +69,59 @@ export const BoxHeaderConfirm = ({
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Variant: Save
|
||||
*
|
||||
* Mirrors `BoxHeaderConfirm` but the primary action is "Save" — used when the
|
||||
* plan editor has unsaved subtask edits.
|
||||
*/
|
||||
export interface BoxHeaderSaveProps {
|
||||
subtitle?: string;
|
||||
onSave?: () => void;
|
||||
onEdit?: () => void;
|
||||
className?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const BoxHeaderSave = ({
|
||||
subtitle,
|
||||
onSave,
|
||||
onEdit,
|
||||
className,
|
||||
loading = false,
|
||||
}: BoxHeaderSaveProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-2 gap-1 flex w-full flex-col items-start justify-between',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="gap-1 px-2.5 pt-2 relative box-border flex w-full items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
buttonContent="icon-only"
|
||||
tone="neutral"
|
||||
buttonRadius="full"
|
||||
className="focus:ring-0 focus-visible:outline-none"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="success"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={onSave}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('layout.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,14 +16,14 @@ import { Button } from '@/components/ui/button';
|
|||
import { type ChatTaskStatusType } from '@/types/constants';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BoxHeaderConfirm, BoxHeaderSplitting } from './BoxHeader';
|
||||
import { BoxHeaderConfirm, BoxHeaderSave } from './BoxHeader';
|
||||
import { FileAttachment, Inputbox, InputboxProps } from './InputBox';
|
||||
import { QueuedBox, QueuedMessage } from './QueuedBox';
|
||||
|
||||
export type BottomBoxState =
|
||||
| 'input'
|
||||
| 'splitting'
|
||||
| 'confirm'
|
||||
| 'save'
|
||||
| 'running'
|
||||
| 'finished';
|
||||
|
||||
|
|
@ -35,11 +35,12 @@ interface BottomBoxProps {
|
|||
queuedMessages?: QueuedMessage[];
|
||||
onRemoveQueuedMessage?: (id: string) => void;
|
||||
|
||||
// Subtask-related props (confirm/splitting state)
|
||||
// Subtask-related props (confirm/save state)
|
||||
subtitle?: string;
|
||||
|
||||
// Action buttons
|
||||
onStartTask?: () => void;
|
||||
onSavePlan?: () => void;
|
||||
onEdit?: () => void;
|
||||
|
||||
// Task info
|
||||
|
|
@ -67,6 +68,7 @@ export default function BottomBox({
|
|||
onRemoveQueuedMessage,
|
||||
subtitle,
|
||||
onStartTask,
|
||||
onSavePlan,
|
||||
onEdit,
|
||||
inputProps,
|
||||
loading = false,
|
||||
|
|
@ -78,9 +80,7 @@ export default function BottomBox({
|
|||
|
||||
// Background color reflects current state only
|
||||
let backgroundClass = 'bg-ds-bg-neutral-subtle-default';
|
||||
if (state === 'splitting')
|
||||
backgroundClass = 'bg-ds-bg-splitting-subtle-default';
|
||||
else if (state === 'confirm')
|
||||
if (state === 'confirm' || state === 'save')
|
||||
backgroundClass = 'bg-ds-bg-completed-subtle-default';
|
||||
|
||||
return (
|
||||
|
|
@ -99,7 +99,6 @@ export default function BottomBox({
|
|||
className={`rounded-3xl mb-sm relative flex w-full flex-col ${backgroundClass}`}
|
||||
>
|
||||
{/* BoxHeader variants */}
|
||||
{state === 'splitting' && <BoxHeaderSplitting />}
|
||||
{state === 'confirm' && (
|
||||
<BoxHeaderConfirm
|
||||
subtitle={subtitle}
|
||||
|
|
@ -108,6 +107,14 @@ export default function BottomBox({
|
|||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
{state === 'save' && (
|
||||
<BoxHeaderSave
|
||||
subtitle={subtitle}
|
||||
onSave={onSavePlan}
|
||||
onEdit={onEdit}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Inputbox (always visible) */}
|
||||
<Inputbox {...inputProps} />
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import tokenDarkIcon from '@/assets/token-dark.svg';
|
||||
import tokenLightIcon from '@/assets/token-light.svg';
|
||||
import { ClipboardList } from '@/components/ui/animate-ui/icons/clipboard-list';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { VanillaChatStore } from '@/store/chatStore';
|
||||
import { AgentStep, ChatTaskStatus } from '@/types/constants';
|
||||
import { useEffect, useState, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AnimatedTokenNumber, formatSplittingElapsed } from './TokenUtils';
|
||||
|
||||
/** Shared start wall time when the task has no `taskTime` / `elapsed` yet (keeps a stable clock across remounts). */
|
||||
const splittingTimerStartMsByTaskId = new Map<string, number>();
|
||||
|
||||
/** Matches work-log / `getFormattedTaskTime` (TaskWorkLogAccordion `getTaskElapsedMs`). */
|
||||
function getTaskElapsedMs(task: { taskTime: number; elapsed: number }): number {
|
||||
if (task.taskTime !== 0) {
|
||||
return Math.max(0, Date.now() - task.taskTime + task.elapsed);
|
||||
}
|
||||
return Math.max(0, task.elapsed);
|
||||
}
|
||||
|
||||
function getOrCreateSplittingMapStart(taskId: string): number {
|
||||
let start = splittingTimerStartMsByTaskId.get(taskId);
|
||||
if (start === undefined) {
|
||||
start = Date.now();
|
||||
splittingTimerStartMsByTaskId.set(taskId, start);
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
function clearSplittingTimerStart(taskId: string) {
|
||||
splittingTimerStartMsByTaskId.delete(taskId);
|
||||
}
|
||||
|
||||
function isSplittingSkeletonPhase(task: any): boolean {
|
||||
if (!task) return false;
|
||||
const anyToSubTasksMessage = task.messages?.find(
|
||||
(m: any) => m.step === AgentStep.TO_SUB_TASKS
|
||||
);
|
||||
return (
|
||||
(task.status !== ChatTaskStatus.FINISHED &&
|
||||
task.status !== ChatTaskStatus.RUNNING &&
|
||||
!anyToSubTasksMessage &&
|
||||
!task.hasWaitComfirm &&
|
||||
(task.messages?.length ?? 0) > 0) ||
|
||||
(task.isTakeControl && !anyToSubTasksMessage)
|
||||
);
|
||||
}
|
||||
|
||||
function useSplittingPhaseElapsedMs(
|
||||
chatStore: VanillaChatStore,
|
||||
taskId: string | null
|
||||
): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
const tick = () => setNow(Date.now());
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
const sync = () => {
|
||||
const task = chatStore.getState().tasks[taskId];
|
||||
if (!isSplittingSkeletonPhase(task)) {
|
||||
clearSplittingTimerStart(taskId);
|
||||
}
|
||||
};
|
||||
sync();
|
||||
const unsubscribe = chatStore.subscribe(sync);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
clearSplittingTimerStart(taskId);
|
||||
};
|
||||
}, [chatStore, taskId]);
|
||||
|
||||
if (!taskId) return 0;
|
||||
const task = chatStore.getState().tasks[taskId];
|
||||
if (!task) return 0;
|
||||
const fromTask = getTaskElapsedMs(task);
|
||||
if (fromTask > 0 || task.taskTime !== 0) {
|
||||
return fromTask;
|
||||
}
|
||||
return Math.max(0, now - getOrCreateSplittingMapStart(taskId));
|
||||
}
|
||||
|
||||
export interface SplittingProgressRowProps {
|
||||
chatStore: VanillaChatStore;
|
||||
taskId: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SplittingProgressRow({
|
||||
chatStore,
|
||||
taskId,
|
||||
className,
|
||||
}: SplittingProgressRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { appearance } = useAuthStore();
|
||||
const tokenIcon = appearance === 'dark' ? tokenDarkIcon : tokenLightIcon;
|
||||
const elapsedMs = useSplittingPhaseElapsedMs(chatStore, taskId);
|
||||
|
||||
const tokens = useSyncExternalStore(
|
||||
(cb) => chatStore.subscribe(cb),
|
||||
() => (taskId ? (chatStore.getState().tasks[taskId]?.tokens ?? 0) : 0),
|
||||
() => (taskId ? (chatStore.getState().tasks[taskId]?.tokens ?? 0) : 0)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'gap-x-2 gap-y-1 min-w-0 py-2 mx-3 flex w-full flex-wrap items-center justify-start',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-center">
|
||||
<ClipboardList
|
||||
animate
|
||||
loop
|
||||
size={16}
|
||||
className="text-ds-icon-information-default-default"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-body-sm font-medium text-ds-text-information-default-default shrink-0">
|
||||
{t('chat.splitting-tasks')}
|
||||
</span>
|
||||
<span className="text-body-sm font-normal text-ds-text-neutral-subtle-default shrink-0 tabular-nums">
|
||||
{formatSplittingElapsed(elapsedMs)}
|
||||
</span>
|
||||
<span className="text-body-sm font-normal text-ds-text-neutral-subtle-default shrink-0 tabular-nums">
|
||||
{' '}
|
||||
•{' '}
|
||||
</span>
|
||||
<span
|
||||
className="gap-1 text-body-sm font-normal text-ds-text-neutral-subtle-default flex shrink-0 items-center"
|
||||
aria-label={`${t('chat.token')}: ${tokens}`}
|
||||
>
|
||||
<AnimatedTokenNumber value={tokens} />
|
||||
tokens
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
src/components/ChatBox/TaskBox/PlanTaskBox/ExpandedOverlay.tsx
Normal file
167
src/components/ChatBox/TaskBox/PlanTaskBox/ExpandedOverlay.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { UserMessageRichContent } from '@/components/ChatBox/MessageItem/UserMessageRichContent';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { VanillaChatStore } from '@/store/chatStore';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CircleDashed, LoaderCircle, Minimize2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatusRow } from './StatusRow';
|
||||
import { SubtaskEditor } from './SubtaskEditor';
|
||||
import { parseStreamingTasks, planOverlayScaleMotion } from './utils';
|
||||
|
||||
interface ExpandedOverlayProps {
|
||||
chatStore: VanillaChatStore;
|
||||
taskId: string;
|
||||
userPrompt?: string;
|
||||
taskInfo: TaskInfo[];
|
||||
streamingDecomposeText: string;
|
||||
isSplitting: boolean;
|
||||
bottomOffsetPx: number;
|
||||
onMinimize: () => void;
|
||||
onAddTask: () => void;
|
||||
onUpdateTask: (index: number, content: string) => void;
|
||||
onDeleteTask: (index: number) => void;
|
||||
onMarkDirty: () => void;
|
||||
}
|
||||
|
||||
export function ExpandedOverlay({
|
||||
chatStore,
|
||||
taskId,
|
||||
userPrompt,
|
||||
taskInfo,
|
||||
streamingDecomposeText,
|
||||
isSplitting,
|
||||
bottomOffsetPx,
|
||||
onMinimize,
|
||||
onAddTask,
|
||||
onUpdateTask,
|
||||
onDeleteTask,
|
||||
onMarkDirty,
|
||||
}: ExpandedOverlayProps) {
|
||||
const { t } = useTranslation();
|
||||
const streamingTasks = useMemo(
|
||||
() => parseStreamingTasks(streamingDecomposeText),
|
||||
[streamingDecomposeText]
|
||||
);
|
||||
|
||||
const hasTaskInfo = taskInfo.length > 0;
|
||||
// Ensure the editor always has at least one trailing row to type into.
|
||||
const [trailingRowAdded, setTrailingRowAdded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasTaskInfo &&
|
||||
!trailingRowAdded &&
|
||||
taskInfo.every((t) => t.content.trim() !== '')
|
||||
) {
|
||||
onAddTask();
|
||||
setTrailingRowAdded(true);
|
||||
}
|
||||
}, [hasTaskInfo, taskInfo, trailingRowAdded, onAddTask]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={planOverlayScaleMotion.initial}
|
||||
animate={planOverlayScaleMotion.animate}
|
||||
exit={planOverlayScaleMotion.exit}
|
||||
transition={planOverlayScaleMotion.transition}
|
||||
className="inset-x-0 absolute z-30 flex justify-center"
|
||||
style={{
|
||||
top: 0,
|
||||
bottom: bottomOffsetPx + 8,
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
>
|
||||
<div className="px-sm flex w-full max-w-[600px] flex-col">
|
||||
<div className="rounded-2xl bg-ds-bg-splitting-subtle-default border-ds-border-neutral-subtle-disabled min-h-0 flex h-full flex-col overflow-hidden border border-solid">
|
||||
<div className="gap-2 px-3 pt-2 flex shrink-0 items-center">
|
||||
<div className="text-body-sm font-bold text-ds-text-neutral-default-default min-w-0 flex-1">
|
||||
{t('chat.subtasks-planning')}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
buttonContent="icon-only"
|
||||
buttonRadius="full"
|
||||
onClick={onMinimize}
|
||||
aria-label={t('chat.minimize-plan')}
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{userPrompt ? (
|
||||
<div className="px-3 py-2 shrink-0">
|
||||
<UserMessageRichContent
|
||||
content={userPrompt}
|
||||
variant="compact"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSplitting && (
|
||||
<div className="px-3 py-2 shrink-0">
|
||||
<StatusRow chatStore={chatStore} taskId={taskId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="scrollbar px-2 min-h-0 border-ds-border-neutral-subtle-disabled flex-1 overflow-y-auto border border-x-0 border-t-1 border-b-0 border-solid bg-transparent">
|
||||
{hasTaskInfo ? (
|
||||
<SubtaskEditor
|
||||
taskInfo={taskInfo}
|
||||
onAdd={onAddTask}
|
||||
onUpdate={onUpdateTask}
|
||||
onDelete={onDeleteTask}
|
||||
onMarkDirty={onMarkDirty}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 flex flex-col">
|
||||
{streamingTasks.tasks.map((content, i) => {
|
||||
const isLast = i === streamingTasks.tasks.length - 1;
|
||||
const streaming = isLast && streamingTasks.isStreaming;
|
||||
return (
|
||||
<div
|
||||
key={`s-${i}`}
|
||||
className="gap-2 py-1.5 animate-in fade-in-0 slide-in-from-left-2 flex items-start duration-300"
|
||||
>
|
||||
<div className="h-4 pt-0.5 flex flex-shrink-0 items-center justify-center">
|
||||
{streaming ? (
|
||||
<LoaderCircle
|
||||
size={13}
|
||||
className="animate-spin text-ds-icon-information-default-default"
|
||||
/>
|
||||
) : (
|
||||
<CircleDashed
|
||||
size={13}
|
||||
className="text-ds-icon-neutral-muted-default"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-label-xs text-ds-text-neutral-default-default min-w-0 flex-1">
|
||||
{content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
155
src/components/ChatBox/TaskBox/PlanTaskBox/FoldedView.tsx
Normal file
155
src/components/ChatBox/TaskBox/PlanTaskBox/FoldedView.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { VanillaChatStore } from '@/store/chatStore';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Circle, CircleDashed, Maximize2 } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatusRow } from './StatusRow';
|
||||
import { parseStreamingTasks, planBlurFadeMotion } from './utils';
|
||||
|
||||
interface FoldedViewProps {
|
||||
chatStore: VanillaChatStore;
|
||||
taskId: string;
|
||||
summaryTask: string;
|
||||
taskInfo: TaskInfo[];
|
||||
streamingDecomposeText: string;
|
||||
isSplitting: boolean;
|
||||
canExpand?: boolean;
|
||||
onExpand: () => void;
|
||||
}
|
||||
|
||||
const PREVIEW_MAX_HEIGHT_PX = 200;
|
||||
|
||||
export function FoldedView({
|
||||
chatStore,
|
||||
taskId,
|
||||
summaryTask,
|
||||
taskInfo,
|
||||
streamingDecomposeText,
|
||||
isSplitting,
|
||||
canExpand = true,
|
||||
onExpand,
|
||||
}: FoldedViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const streamingTasks = useMemo(
|
||||
() => parseStreamingTasks(streamingDecomposeText),
|
||||
[streamingDecomposeText]
|
||||
);
|
||||
|
||||
const hasTaskInfo = taskInfo.length > 0;
|
||||
const hasStreaming = streamingTasks.tasks.length > 0;
|
||||
const showPreview = hasTaskInfo || hasStreaming;
|
||||
|
||||
const previewRows = hasTaskInfo
|
||||
? taskInfo
|
||||
.filter((t) => t.content !== '')
|
||||
.map((t, i) => ({
|
||||
key: t.id || `task-${i}`,
|
||||
content: t.content,
|
||||
streaming: false,
|
||||
}))
|
||||
: streamingTasks.tasks.map((content, i) => ({
|
||||
key: `stream-${i}`,
|
||||
content,
|
||||
streaming:
|
||||
i === streamingTasks.tasks.length - 1 && streamingTasks.isStreaming,
|
||||
}));
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={planBlurFadeMotion.initial}
|
||||
animate={planBlurFadeMotion.animate}
|
||||
exit={planBlurFadeMotion.exit}
|
||||
transition={planBlurFadeMotion.transition}
|
||||
className={cn(
|
||||
'rounded-2xl bg-ds-bg-splitting-subtle-default mx-sm relative flex flex-col overflow-hidden'
|
||||
)}
|
||||
>
|
||||
<div className="gap-2 px-3 py-2 border-ds-border-neutral-subtle-default flex items-center border-x-0 border-t-0 border-b border-solid">
|
||||
<div className="text-body-sm font-bold text-ds-text-neutral-default-default min-w-0 flex-1 truncate">
|
||||
{summaryTask || t('chat.subtasks-planning')}
|
||||
</div>
|
||||
{canExpand ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
buttonContent="icon-only"
|
||||
buttonRadius="full"
|
||||
onClick={onExpand}
|
||||
aria-label={t('chat.expand-plan')}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isSplitting && !showPreview && (
|
||||
<div className="px-3 py-3">
|
||||
<StatusRow chatStore={chatStore} taskId={taskId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPreview && (
|
||||
<div
|
||||
className="scrollbar m-2 rounded-xl relative overflow-y-auto bg-transparent"
|
||||
style={{ height: PREVIEW_MAX_HEIGHT_PX }}
|
||||
>
|
||||
<div className="px-3 py-2 flex flex-col">
|
||||
{previewRows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className="gap-2 py-1.5 animate-in fade-in-0 slide-in-from-left-2 flex items-start duration-300"
|
||||
>
|
||||
<div className="h-4 pt-0.5 flex flex-shrink-0 items-center justify-center">
|
||||
{row.streaming ? (
|
||||
<Circle
|
||||
size={13}
|
||||
className="text-ds-icon-status-splitting-default-default"
|
||||
/>
|
||||
) : (
|
||||
<CircleDashed
|
||||
size={13}
|
||||
className="text-ds-icon-neutral-muted-default"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-label-xs text-ds-text-neutral-default-default min-w-0 flex-1">
|
||||
{row.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{canExpand ? (
|
||||
<div className="to-ds-bg-status-splitting-subtle-default bottom-0 -mx-2 h-16 pointer-events-none absolute left-1/2 w-full -translate-x-1/2 bg-gradient-to-b from-transparent">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
buttonRadius="full"
|
||||
onClick={onExpand}
|
||||
aria-label={t('chat.expand-subtasks')}
|
||||
className="bottom-3 pointer-events-auto absolute left-1/2 -translate-x-1/2"
|
||||
>
|
||||
{t('chat.expand-subtasks')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
69
src/components/ChatBox/TaskBox/PlanTaskBox/StatusRow.tsx
Normal file
69
src/components/ChatBox/TaskBox/PlanTaskBox/StatusRow.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import {
|
||||
AnimatedTokenNumber,
|
||||
formatSplittingElapsed,
|
||||
} from '@/components/ChatBox/MessageItem/TokenUtils';
|
||||
import { ClipboardList } from '@/components/ui/animate-ui/icons/clipboard-list';
|
||||
import { AnimateIcon } from '@/components/ui/animate-ui/icons/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { VanillaChatStore } from '@/store/chatStore';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSplittingElapsedMs } from './utils';
|
||||
|
||||
interface StatusRowProps {
|
||||
chatStore: VanillaChatStore;
|
||||
taskId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Animated icon + "Splitting tasks" label + elapsed + token count. */
|
||||
export function StatusRow({ chatStore, taskId, className }: StatusRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const elapsedMs = useSplittingElapsedMs(chatStore, taskId);
|
||||
const tokens = useSyncExternalStore(
|
||||
(cb) => chatStore.subscribe(cb),
|
||||
() => chatStore.getState().tasks[taskId]?.tokens ?? 0,
|
||||
() => chatStore.getState().tasks[taskId]?.tokens ?? 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('gap-x-2 gap-y-1 flex flex-wrap items-center', className)}
|
||||
>
|
||||
<AnimateIcon
|
||||
animate
|
||||
loop
|
||||
className="h-4 w-4 !text-ds-text-information-default-default flex shrink-0 items-center justify-center"
|
||||
>
|
||||
<ClipboardList size={16} />
|
||||
</AnimateIcon>
|
||||
<span className="text-body-sm font-bold text-ds-text-information-default-default shrink-0">
|
||||
{t('chat.splitting-tasks')}
|
||||
</span>
|
||||
<span className="text-body-sm font-normal text-ds-text-neutral-subtle-default shrink-0 tabular-nums">
|
||||
{formatSplittingElapsed(elapsedMs)}
|
||||
</span>
|
||||
<span className="text-body-sm font-normal text-ds-text-neutral-subtle-default shrink-0">
|
||||
•
|
||||
</span>
|
||||
<span className="gap-1 text-body-sm font-normal text-ds-text-neutral-subtle-default flex shrink-0 items-center">
|
||||
<AnimatedTokenNumber value={tokens} />
|
||||
tokens
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/components/ChatBox/TaskBox/PlanTaskBox/SubtaskEditor.tsx
Normal file
111
src/components/ChatBox/TaskBox/PlanTaskBox/SubtaskEditor.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { CircleDashed } from 'lucide-react';
|
||||
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SubtaskEditorProps {
|
||||
taskInfo: TaskInfo[];
|
||||
onAdd: () => void;
|
||||
onUpdate: (index: number, content: string) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onMarkDirty: () => void;
|
||||
}
|
||||
|
||||
export function SubtaskEditor({
|
||||
taskInfo,
|
||||
onAdd,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onMarkDirty,
|
||||
}: SubtaskEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const inputRefs = useRef<Array<HTMLTextAreaElement | null>>([]);
|
||||
const [focusIndex, setFocusIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusIndex === null) return;
|
||||
const el = inputRefs.current[focusIndex];
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
}
|
||||
setFocusIndex(null);
|
||||
}, [focusIndex, taskInfo.length]);
|
||||
|
||||
const handleKey = (
|
||||
e: KeyboardEvent<HTMLTextAreaElement>,
|
||||
index: number,
|
||||
content: string
|
||||
) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onMarkDirty();
|
||||
onAdd();
|
||||
setFocusIndex(taskInfo.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Backspace' && content === '' && taskInfo.length > 1) {
|
||||
e.preventDefault();
|
||||
onMarkDirty();
|
||||
onDelete(index);
|
||||
setFocusIndex(Math.max(0, index - 1));
|
||||
}
|
||||
};
|
||||
|
||||
const autoResize = (el: HTMLTextAreaElement | null) => {
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-1 py-1 flex flex-col">
|
||||
{taskInfo.map((task, index) => (
|
||||
<div
|
||||
key={task.id || `row-${index}`}
|
||||
className="gap-2 p-1 flex items-start"
|
||||
>
|
||||
<div className="h-6 flex shrink-0 items-center justify-center">
|
||||
<CircleDashed
|
||||
size={16}
|
||||
className="text-ds-icon-status-splitting-default-default mt-0.5 fill-current"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
inputRefs.current[index] = el;
|
||||
autoResize(el);
|
||||
}}
|
||||
value={task.content}
|
||||
placeholder={
|
||||
index === taskInfo.length - 1
|
||||
? t('chat.add-subtask-placeholder')
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => {
|
||||
onMarkDirty();
|
||||
onUpdate(index, e.target.value);
|
||||
autoResize(e.currentTarget);
|
||||
}}
|
||||
onKeyDown={(e) => handleKey(e, index, task.content)}
|
||||
rows={1}
|
||||
className="text-body-sm font-normal text-ds-text-neutral-default-default placeholder:text-ds-text-neutral-subtle-disabled min-w-0 leading-10 flex-1 resize-none border-none bg-transparent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
src/components/ChatBox/TaskBox/PlanTaskBox/index.tsx
Normal file
208
src/components/ChatBox/TaskBox/PlanTaskBox/index.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import type { VanillaChatStore } from '@/store/chatStore';
|
||||
import { AgentStep, ChatTaskStatus } from '@/types/constants';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ExpandedOverlay } from './ExpandedOverlay';
|
||||
import { FoldedView } from './FoldedView';
|
||||
import { isPlanSplittingPhase, useWordTypingBuffer } from './utils';
|
||||
|
||||
/** ID of the slot element rendered by ChatBox for portaling the expanded overlay. */
|
||||
export const PLAN_OVERLAY_SLOT_ID = 'plan-task-overlay-root';
|
||||
|
||||
interface PlanTaskBoxProps {
|
||||
chatStore: VanillaChatStore;
|
||||
taskId: string;
|
||||
userPrompt?: string;
|
||||
allowOverlay?: boolean;
|
||||
}
|
||||
|
||||
export function PlanTaskBox({
|
||||
chatStore,
|
||||
taskId,
|
||||
userPrompt,
|
||||
allowOverlay = true,
|
||||
}: PlanTaskBoxProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [overlayRoot, setOverlayRoot] = useState<HTMLElement | null>(null);
|
||||
const [bottomOffsetPx, setBottomOffsetPx] = useState(160);
|
||||
const manuallyMinimizedPlanRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowOverlay) {
|
||||
setOverlayRoot(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const syncOverlayRoot = () => {
|
||||
setOverlayRoot(document.getElementById(PLAN_OVERLAY_SLOT_ID));
|
||||
};
|
||||
|
||||
syncOverlayRoot();
|
||||
const id = window.requestAnimationFrame(syncOverlayRoot);
|
||||
return () => window.cancelAnimationFrame(id);
|
||||
}, [allowOverlay]);
|
||||
|
||||
// Track the current BottomBox height so the overlay sits just above it.
|
||||
useEffect(() => {
|
||||
if (!expanded || !allowOverlay) return;
|
||||
const measure = () => {
|
||||
const slot = document.getElementById(PLAN_OVERLAY_SLOT_ID);
|
||||
const bottomBoxEl =
|
||||
slot?.parentElement?.querySelector<HTMLElement>(
|
||||
'[data-bottom-box-overlay]'
|
||||
) || null;
|
||||
const h = bottomBoxEl?.offsetHeight ?? 160;
|
||||
setBottomOffsetPx(h);
|
||||
};
|
||||
measure();
|
||||
const slot = document.getElementById(PLAN_OVERLAY_SLOT_ID);
|
||||
const bottomBoxEl =
|
||||
slot?.parentElement?.querySelector<HTMLElement>(
|
||||
'[data-bottom-box-overlay]'
|
||||
) || null;
|
||||
if (!bottomBoxEl || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(bottomBoxEl);
|
||||
return () => observer.disconnect();
|
||||
}, [allowOverlay, expanded]);
|
||||
|
||||
const task = useSyncExternalStore(
|
||||
(cb) => chatStore.subscribe(cb),
|
||||
() => chatStore.getState().tasks[taskId],
|
||||
() => chatStore.getState().tasks[taskId]
|
||||
);
|
||||
|
||||
const streamingDecomposeText = useSyncExternalStore(
|
||||
(cb) => chatStore.subscribe(cb),
|
||||
() => chatStore.getState().tasks[taskId]?.streamingDecomposeText ?? '',
|
||||
() => chatStore.getState().tasks[taskId]?.streamingDecomposeText ?? ''
|
||||
);
|
||||
|
||||
const pacedStreamingText = useWordTypingBuffer(streamingDecomposeText, {
|
||||
intervalMs: 45,
|
||||
punctuationPauseMs: 140,
|
||||
});
|
||||
const latestPlanMessage = useMemo(
|
||||
() =>
|
||||
task?.messages
|
||||
?.slice()
|
||||
.reverse()
|
||||
.find((m: Message) => m.step === AgentStep.TO_SUB_TASKS),
|
||||
[task?.messages]
|
||||
);
|
||||
|
||||
const latestPlanKey = latestPlanMessage?.id || '';
|
||||
const hasUnconfirmedPlan = Boolean(
|
||||
latestPlanMessage && !latestPlanMessage.isConfirm
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowOverlay) return;
|
||||
if (!latestPlanKey || !hasUnconfirmedPlan) {
|
||||
setExpanded(false);
|
||||
return;
|
||||
}
|
||||
if (manuallyMinimizedPlanRef.current === latestPlanKey) return;
|
||||
setExpanded(true);
|
||||
}, [allowOverlay, hasUnconfirmedPlan, latestPlanKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
task?.status === ChatTaskStatus.RUNNING ||
|
||||
task?.status === ChatTaskStatus.FINISHED ||
|
||||
latestPlanMessage?.isConfirm
|
||||
) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [latestPlanMessage?.isConfirm, task?.status]);
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
const isSplitting = isPlanSplittingPhase(task);
|
||||
|
||||
const markDirty = () => chatStore.getState().setPlanDirty(taskId, true);
|
||||
|
||||
const handleAddTask = () => {
|
||||
chatStore.getState().addTaskInfo();
|
||||
};
|
||||
const handleUpdateTask = (index: number, content: string) => {
|
||||
chatStore.getState().updateTaskInfo(index, content);
|
||||
};
|
||||
const handleDeleteTask = (index: number) => {
|
||||
chatStore.getState().deleteTaskInfo(index);
|
||||
};
|
||||
const handleExpand = () => {
|
||||
if (!allowOverlay) return;
|
||||
setExpanded(true);
|
||||
};
|
||||
const handleMinimize = () => {
|
||||
manuallyMinimizedPlanRef.current = latestPlanKey || null;
|
||||
setExpanded(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{!expanded && (
|
||||
<FoldedView
|
||||
key="folded"
|
||||
chatStore={chatStore}
|
||||
taskId={taskId}
|
||||
summaryTask={task.summaryTask || ''}
|
||||
taskInfo={task.taskInfo || []}
|
||||
streamingDecomposeText={pacedStreamingText}
|
||||
isSplitting={isSplitting}
|
||||
canExpand={allowOverlay}
|
||||
onExpand={handleExpand}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{overlayRoot && allowOverlay
|
||||
? createPortal(
|
||||
<AnimatePresence mode="wait">
|
||||
{expanded ? (
|
||||
<ExpandedOverlay
|
||||
key="expanded"
|
||||
chatStore={chatStore}
|
||||
taskId={taskId}
|
||||
userPrompt={userPrompt}
|
||||
taskInfo={task.taskInfo || []}
|
||||
streamingDecomposeText={pacedStreamingText}
|
||||
isSplitting={isSplitting}
|
||||
bottomOffsetPx={bottomOffsetPx}
|
||||
onMinimize={handleMinimize}
|
||||
onAddTask={handleAddTask}
|
||||
onUpdateTask={handleUpdateTask}
|
||||
onDeleteTask={handleDeleteTask}
|
||||
onMarkDirty={markDirty}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>,
|
||||
overlayRoot
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
195
src/components/ChatBox/TaskBox/PlanTaskBox/utils.ts
Normal file
195
src/components/ChatBox/TaskBox/PlanTaskBox/utils.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import type { VanillaChatStore } from '@/store/chatStore';
|
||||
import { AgentStep, ChatTaskStatus } from '@/types/constants';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
/** Parse `<task>...</task>` tokens out of a streaming decompose blob. */
|
||||
export function parseStreamingTasks(text: string): {
|
||||
tasks: string[];
|
||||
isStreaming: boolean;
|
||||
} {
|
||||
const tasks: string[] = [];
|
||||
const completeTaskRegex = /<task>([\s\S]*?)<\/task>/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = completeTaskRegex.exec(text)) !== null) {
|
||||
const content = match[1].trim();
|
||||
if (content) tasks.push(content);
|
||||
}
|
||||
|
||||
const lastOpen = text.lastIndexOf('<task>');
|
||||
const lastClose = text.lastIndexOf('</task>');
|
||||
let isStreaming = false;
|
||||
if (lastOpen > lastClose) {
|
||||
const incomplete = text.substring(lastOpen + 6).trim();
|
||||
if (incomplete) {
|
||||
tasks.push(incomplete);
|
||||
isStreaming = true;
|
||||
}
|
||||
}
|
||||
return { tasks, isStreaming };
|
||||
}
|
||||
|
||||
/** True while the task is between user-prompt and `to_sub_tasks` arrival. */
|
||||
export function isPlanSplittingPhase(task: any): boolean {
|
||||
if (!task) return false;
|
||||
const anyToSubTasks = task.messages?.find(
|
||||
(m: any) => m.step === AgentStep.TO_SUB_TASKS
|
||||
);
|
||||
return (
|
||||
(task.status !== ChatTaskStatus.FINISHED &&
|
||||
task.status !== ChatTaskStatus.RUNNING &&
|
||||
!anyToSubTasks &&
|
||||
!task.hasWaitComfirm &&
|
||||
(task.messages?.length ?? 0) > 0) ||
|
||||
(task.isTakeControl && !anyToSubTasks)
|
||||
);
|
||||
}
|
||||
|
||||
const splittingTimerStartByTaskId = new Map<string, number>();
|
||||
|
||||
function getOrCreateStart(taskId: string): number {
|
||||
let start = splittingTimerStartByTaskId.get(taskId);
|
||||
if (start === undefined) {
|
||||
start = Date.now();
|
||||
splittingTimerStartByTaskId.set(taskId, start);
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
function clearStart(taskId: string) {
|
||||
splittingTimerStartByTaskId.delete(taskId);
|
||||
}
|
||||
|
||||
function getTaskElapsedMs(task: { taskTime: number; elapsed: number }): number {
|
||||
if (task.taskTime !== 0) {
|
||||
return Math.max(0, Date.now() - task.taskTime + task.elapsed);
|
||||
}
|
||||
return Math.max(0, task.elapsed);
|
||||
}
|
||||
|
||||
/** Live elapsed-ms during the splitting phase, ticking every 1s. */
|
||||
export function useSplittingElapsedMs(
|
||||
chatStore: VanillaChatStore,
|
||||
taskId: string | null
|
||||
): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
const tick = () => setNow(Date.now());
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
const sync = () => {
|
||||
const task = chatStore.getState().tasks[taskId];
|
||||
if (!isPlanSplittingPhase(task)) clearStart(taskId);
|
||||
};
|
||||
sync();
|
||||
const unsubscribe = chatStore.subscribe(sync);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
clearStart(taskId);
|
||||
};
|
||||
}, [chatStore, taskId]);
|
||||
|
||||
if (!taskId) return 0;
|
||||
const task = chatStore.getState().tasks[taskId];
|
||||
if (!task) return 0;
|
||||
const fromTask = getTaskElapsedMs(task);
|
||||
if (fromTask > 0 || task.taskTime !== 0) return fromTask;
|
||||
return Math.max(0, now - getOrCreateStart(taskId));
|
||||
}
|
||||
|
||||
interface WordTypingOptions {
|
||||
intervalMs?: number;
|
||||
punctuationPauseMs?: number;
|
||||
}
|
||||
|
||||
function nextWordSlice(text: string, fromIndex: number): string {
|
||||
const rest = text.slice(fromIndex);
|
||||
if (!rest) return '';
|
||||
const match = rest.match(/^(\s*\S+\s*)/);
|
||||
return match?.[0] || rest.charAt(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trails a source string with a paced word-by-word display buffer. This keeps
|
||||
* large backend chunks from appearing all at once while still catching up to
|
||||
* incremental streaming responses.
|
||||
*/
|
||||
export function useWordTypingBuffer(
|
||||
sourceText: string,
|
||||
options: WordTypingOptions = {}
|
||||
): string {
|
||||
const { intervalMs = 45, punctuationPauseMs = 140 } = options;
|
||||
const [displayText, setDisplayText] = useState('');
|
||||
|
||||
const source = sourceText || '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) {
|
||||
setDisplayText('');
|
||||
return;
|
||||
}
|
||||
|
||||
setDisplayText((current) => {
|
||||
if (source.startsWith(current)) return current;
|
||||
return '';
|
||||
});
|
||||
}, [source]);
|
||||
|
||||
const delay = useMemo(() => {
|
||||
if (!displayText) return intervalMs;
|
||||
return /[.!?。!?]\s*$/.test(displayText)
|
||||
? punctuationPauseMs
|
||||
: intervalMs;
|
||||
}, [displayText, intervalMs, punctuationPauseMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!source || displayText.length >= source.length) return;
|
||||
if (!source.startsWith(displayText)) return;
|
||||
|
||||
const id = window.setTimeout(() => {
|
||||
setDisplayText((current) => {
|
||||
if (!source.startsWith(current)) return '';
|
||||
if (current.length >= source.length) return current;
|
||||
return current + nextWordSlice(source, current.length);
|
||||
});
|
||||
}, delay);
|
||||
|
||||
return () => window.clearTimeout(id);
|
||||
}, [source, displayText, delay]);
|
||||
|
||||
return displayText;
|
||||
}
|
||||
|
||||
export const planBlurFadeMotion = {
|
||||
initial: { opacity: 0, filter: 'blur(8px)', y: 6 },
|
||||
animate: { opacity: 1, filter: 'blur(0px)', y: 0 },
|
||||
exit: { opacity: 0, filter: 'blur(10px)', y: 4 },
|
||||
transition: { duration: 0.22, ease: [0.4, 0, 0.2, 1] },
|
||||
} as const;
|
||||
|
||||
export const planOverlayScaleMotion = {
|
||||
initial: { opacity: 0, filter: 'blur(12px)', y: 8, scale: 0.8 },
|
||||
animate: { opacity: 1, filter: 'blur(0px)', y: 0, scale: 1 },
|
||||
exit: { opacity: 0, filter: 'blur(12px)', y: 8, scale: 0.8 },
|
||||
transition: { duration: 0.32, ease: [0.16, 1, 0.3, 1] },
|
||||
} as const;
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { CircleDashed, LoaderCircle } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TaskType } from './TaskType';
|
||||
|
||||
interface StreamingTaskListProps {
|
||||
streamingText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse streaming task text and extract task content
|
||||
* Supports formats:
|
||||
* - <task>content</task>
|
||||
* - <task>content (incomplete, still streaming)
|
||||
*/
|
||||
function parseStreamingTasks(text: string): {
|
||||
tasks: string[];
|
||||
isStreaming: boolean;
|
||||
} {
|
||||
const tasks: string[] = [];
|
||||
|
||||
// Match complete tasks: <task>content</task>
|
||||
const completeTaskRegex = /<task>([\s\S]*?)<\/task>/g;
|
||||
let match;
|
||||
while ((match = completeTaskRegex.exec(text)) !== null) {
|
||||
const content = match[1].trim();
|
||||
if (content) {
|
||||
tasks.push(content);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for incomplete task (streaming): <task>content without closing tag
|
||||
const lastOpenTag = text.lastIndexOf('<task>');
|
||||
const lastCloseTag = text.lastIndexOf('</task>');
|
||||
|
||||
let isStreaming = false;
|
||||
if (lastOpenTag > lastCloseTag) {
|
||||
// There's an unclosed <task> tag - extract its content
|
||||
const incompleteContent = text.substring(lastOpenTag + 6).trim();
|
||||
if (incompleteContent) {
|
||||
tasks.push(incompleteContent);
|
||||
isStreaming = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { tasks, isStreaming };
|
||||
}
|
||||
|
||||
export function StreamingTaskList({ streamingText }: StreamingTaskListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { tasks, isStreaming } = useMemo(
|
||||
() => parseStreamingTasks(streamingText),
|
||||
[streamingText]
|
||||
);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
// Show a loading state when no tasks have been parsed yet
|
||||
return (
|
||||
<div className="gap-2 py-sm px-2 flex h-auto w-full flex-col transition-all duration-300">
|
||||
<div className="rounded-xl py-sm bg-ds-bg-neutral-default-default relative h-auto w-full overflow-hidden">
|
||||
<div className="left-0 top-0 absolute w-full bg-transparent">
|
||||
<Progress value={100} className="h-[2px] w-full" />
|
||||
</div>
|
||||
<div className="gap-2 px-sm py-2 flex items-center">
|
||||
<LoaderCircle
|
||||
size={16}
|
||||
className="animate-spin text-ds-icon-information-default-default"
|
||||
/>
|
||||
<span className="animate-pulse text-sm text-ds-text-neutral-subtle-default">
|
||||
{t('layout.task-splitting')}...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gap-2 py-sm px-2 flex h-auto w-full flex-col transition-all duration-300">
|
||||
<div className="rounded-xl py-sm bg-ds-bg-neutral-default-default relative h-auto w-full overflow-hidden">
|
||||
{/* Progress bar at top */}
|
||||
<div className="left-0 top-0 absolute w-full bg-transparent">
|
||||
<Progress value={100} className="h-[2px] w-full" />
|
||||
</div>
|
||||
|
||||
{/* Task type badge */}
|
||||
<div className="mb-2 gap-2 px-sm flex items-center">
|
||||
<TaskType type={1} />
|
||||
<span className="text-xs font-medium text-ds-text-neutral-subtle-default">
|
||||
{t('layout.tasks')} {tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Task list */}
|
||||
<div className="mt-sm px-sm flex flex-col">
|
||||
{tasks.map((task, index) => {
|
||||
const isLastTask = index === tasks.length - 1;
|
||||
const isCurrentlyStreaming = isLastTask && isStreaming;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`streaming-task-${index}`}
|
||||
className="group min-h-2 rounded-lg p-sm animate-in fade-in-0 slide-in-from-left-2 bg-ds-bg-neutral-subtle-default relative flex items-start duration-300"
|
||||
>
|
||||
{/* Task indicator */}
|
||||
<div className="h-4 w-7 pr-sm pt-1 flex flex-shrink-0 items-center justify-center">
|
||||
{isCurrentlyStreaming ? (
|
||||
<LoaderCircle
|
||||
size={13}
|
||||
className="animate-spin text-ds-icon-information-default-default"
|
||||
/>
|
||||
) : (
|
||||
<CircleDashed
|
||||
size={13}
|
||||
className="text-ds-icon-neutral-muted-default"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task content */}
|
||||
<div className="min-h-4 pb-2 relative flex w-full items-start">
|
||||
<span className="text-label-xs text-ds-text-neutral-default-default">
|
||||
{task}
|
||||
{isCurrentlyStreaming && (
|
||||
<span className="ml-0.5 h-4 w-1 animate-pulse bg-ds-icon-information-default-default inline-block" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ChevronDown, LoaderCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TaskType } from './TaskType';
|
||||
|
||||
export const TypeCardSkeleton = ({
|
||||
isTakeControl,
|
||||
}: {
|
||||
isTakeControl: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<div className="gap-2 py-sm px-2 flex h-auto w-full flex-col transition-all duration-300">
|
||||
<div className="rounded-xl py-sm bg-ds-bg-neutral-default-default relative h-auto w-full overflow-hidden">
|
||||
<div className="left-0 top-0 absolute w-full bg-transparent">
|
||||
<Progress value={100} className="h-[2px] w-full" />
|
||||
</div>
|
||||
<div className="mb-2.5 gap-sm px-sm text-sm font-bold leading-13 flex flex-col">
|
||||
<div
|
||||
className={`h-5 bg-ds-bg-neutral-subtle-disabled w-full rounded-full ${
|
||||
!isTakeControl ? 'animate-pulse' : ''
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`h-5 bg-ds-bg-neutral-subtle-disabled w-1/2 rounded-full ${
|
||||
!isTakeControl ? 'animate-pulse' : ''
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`h-5 bg-ds-bg-neutral-subtle-disabled w-1/2 rounded-full ${
|
||||
!isTakeControl ? 'animate-pulse' : ''
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className={`gap-2 px-sm flex items-center justify-between`}>
|
||||
<div className="gap-2 flex items-center">
|
||||
<TaskType type={1} />
|
||||
</div>
|
||||
|
||||
<div className="ease-in-out transition-all duration-300">
|
||||
<div className="gap-2 animate-in fade-in-0 slide-in-from-right-2 flex items-center duration-300">
|
||||
<div className="text-xs font-medium leading-17 text-ds-text-neutral-subtle-default">
|
||||
{t('layout.tasks')}
|
||||
</div>
|
||||
<Button variant="ghost" size="xs" buttonContent="icon-only">
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`rotate-180 transition-transform duration-300`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="ease-in-out overflow-hidden transition-all duration-300">
|
||||
<div className="mt-sm gap-2 px-2 flex flex-col">
|
||||
{[1, 2, 3, 4].map((task: number) => {
|
||||
return (
|
||||
<div
|
||||
key={`taskList-${task}`}
|
||||
className={`gap-2 rounded-lg px-sm py-sm ease-in-out animate-in fade-in-0 slide-in-from-left-2 bg-ds-bg-neutral-default-default flex cursor-pointer border border-solid border-transparent transition-all duration-300`}
|
||||
>
|
||||
<div className="pt-0.5">
|
||||
<LoaderCircle
|
||||
size={16}
|
||||
className={`text-ds-icon-status-running-default-default ${
|
||||
!isTakeControl ? 'animate-spin' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="gap-sm flex flex-1 flex-col items-start justify-center">
|
||||
<div
|
||||
className={`h-5 text-sm font-medium leading-13 bg-ds-bg-neutral-subtle-disabled w-full rounded-full ${
|
||||
!isTakeControl ? 'animate-pulse' : ''
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`h-5 text-sm font-medium leading-13 bg-ds-bg-neutral-subtle-disabled w-1/3 rounded-full ${
|
||||
!isTakeControl ? 'animate-pulse' : ''
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -28,11 +28,11 @@ import React, {
|
|||
import { AgentMessageCard } from './MessageItem/AgentMessageCard';
|
||||
import { NoticeCard } from './MessageItem/NoticeCard';
|
||||
import { PreparingToExecuteTasks } from './MessageItem/PreparingToExecuteTasks';
|
||||
import { SplittingProgressRow } from './MessageItem/SplittingProgressRow';
|
||||
import { TaskCompletionCard } from './MessageItem/TaskCompletionCard';
|
||||
import { TaskWorkLogAccordion } from './MessageItem/TaskWorkLogAccordion';
|
||||
import { UserMessageCard } from './MessageItem/UserMessageCard';
|
||||
import { StreamingTaskList } from './TaskBox/StreamingTaskList';
|
||||
import { PlanTaskBox } from './TaskBox/PlanTaskBox';
|
||||
import { isPlanSplittingPhase } from './TaskBox/PlanTaskBox/utils';
|
||||
import { TaskCard } from './TaskBox/TaskCard';
|
||||
|
||||
/** Collapsible card that shows a single agent's result (workforce / non–single-agent turns). */
|
||||
|
|
@ -214,15 +214,25 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
// Only show during active phases (not finished)
|
||||
chatState.tasks[activeTaskId].status !== ChatTaskStatus.FINISHED;
|
||||
|
||||
// Only show the fallback task box for the newest query while the agent is still splitting work.
|
||||
// Simple Q&A sessions set hasWaitComfirm to true, so we should not render an empty task box there.
|
||||
// Also, do not show fallback task if we are currently decomposing (streaming text).
|
||||
const isDecomposing = streamingDecomposeText.length > 0;
|
||||
const activeTask = activeTaskId ? chatState.tasks[activeTaskId] : undefined;
|
||||
const hasUnconfirmedPlan = Boolean(
|
||||
activeTask?.messages.some(
|
||||
(m: any) => m.step === AgentStep.TO_SUB_TASKS && !m.isConfirm
|
||||
)
|
||||
);
|
||||
const isPlanningPhase = Boolean(
|
||||
activeTask &&
|
||||
!activeTask.hasWaitComfirm &&
|
||||
(isPlanSplittingPhase(activeTask) ||
|
||||
streamingDecomposeText.length > 0 ||
|
||||
hasUnconfirmedPlan)
|
||||
);
|
||||
|
||||
// Show the fallback task box for the newest query only while the agent is
|
||||
// actually planning. Direct running tasks without `to_sub_tasks` should stay
|
||||
// in the normal running/input path.
|
||||
const shouldShowFallbackTask =
|
||||
isLastUserQuery &&
|
||||
activeTaskId &&
|
||||
!chatState.tasks[activeTaskId].hasWaitComfirm &&
|
||||
!isDecomposing;
|
||||
isLastUserQuery && activeTaskId && isPlanningPhase;
|
||||
|
||||
const task =
|
||||
(queryGroup.taskMessage || shouldShowFallbackTask) && activeTaskId
|
||||
|
|
@ -294,17 +304,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
}, [task]);
|
||||
|
||||
// Check if we're in skeleton phase
|
||||
const anyToSubTasksMessage = task?.messages.find(
|
||||
(m: any) => m.step === AgentStep.TO_SUB_TASKS
|
||||
);
|
||||
const isSkeletonPhase =
|
||||
task &&
|
||||
((task.status !== ChatTaskStatus.FINISHED &&
|
||||
task.status !== ChatTaskStatus.RUNNING &&
|
||||
!anyToSubTasksMessage &&
|
||||
!task.hasWaitComfirm &&
|
||||
task.messages.length > 0) ||
|
||||
(task.isTakeControl && !anyToSubTasksMessage));
|
||||
const isSkeletonPhase = task && isPlanSplittingPhase(task);
|
||||
|
||||
/** Task card visible (user message is sticky alone in this mode). */
|
||||
const taskCardVisible = Boolean(task) && !isSkeletonPhase && !isHumanReply;
|
||||
|
|
@ -375,7 +375,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
</motion.div>
|
||||
)}
|
||||
|
||||
{taskCardVisible && (
|
||||
{taskCardVisible && activeTaskId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{
|
||||
|
|
@ -393,32 +393,37 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
transformOrigin: 'top',
|
||||
}}
|
||||
>
|
||||
<TaskCard
|
||||
key={`task-${activeTaskId}-${queryGroup.queryId}`}
|
||||
chatId={chatId}
|
||||
taskInfo={task?.taskInfo || []}
|
||||
taskType={queryGroup.taskMessage?.taskType || 1}
|
||||
taskAssigning={task?.taskAssigning || []}
|
||||
taskRunning={task?.taskRunning || []}
|
||||
progressValue={task?.progressValue || 0}
|
||||
summaryTask={task?.summaryTask || ''}
|
||||
onAddTask={() => {
|
||||
chatState.setIsTaskEdit(activeTaskId as string, true);
|
||||
chatState.addTaskInfo();
|
||||
}}
|
||||
onUpdateTask={(taskIndex, content) => {
|
||||
chatState.setIsTaskEdit(activeTaskId as string, true);
|
||||
chatState.updateTaskInfo(taskIndex, content);
|
||||
}}
|
||||
onSaveTask={() => {
|
||||
chatState.saveTaskInfo();
|
||||
}}
|
||||
onDeleteTask={(taskIndex) => {
|
||||
chatState.setIsTaskEdit(activeTaskId as string, true);
|
||||
chatState.deleteTaskInfo(taskIndex);
|
||||
}}
|
||||
clickable={true}
|
||||
/>
|
||||
{hasConfirmedSubTasks ? (
|
||||
<TaskCard
|
||||
key={`task-${activeTaskId}-${queryGroup.queryId}`}
|
||||
chatId={chatId}
|
||||
taskInfo={task?.taskInfo || []}
|
||||
taskType={queryGroup.taskMessage?.taskType || 1}
|
||||
taskAssigning={task?.taskAssigning || []}
|
||||
taskRunning={task?.taskRunning || []}
|
||||
progressValue={task?.progressValue || 0}
|
||||
summaryTask={task?.summaryTask || ''}
|
||||
onAddTask={() => {
|
||||
chatState.addTaskInfo();
|
||||
}}
|
||||
onUpdateTask={(taskIndex, content) => {
|
||||
chatState.updateTaskInfo(taskIndex, content);
|
||||
}}
|
||||
onSaveTask={() => {
|
||||
chatState.saveTaskInfo();
|
||||
}}
|
||||
onDeleteTask={(taskIndex) => {
|
||||
chatState.deleteTaskInfo(taskIndex);
|
||||
}}
|
||||
clickable={true}
|
||||
/>
|
||||
) : (
|
||||
<PlanTaskBox
|
||||
chatStore={chatStore}
|
||||
taskId={activeTaskId}
|
||||
userPrompt={queryGroup.userMessage?.content}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
@ -624,11 +629,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Streaming Decompose Text */}
|
||||
{isLastUserQuery && streamingDecomposeText && (
|
||||
<StreamingTaskList streamingText={streamingDecomposeText} />
|
||||
)}
|
||||
|
||||
{/* PlanTaskBox now owns streaming + skeleton splitting UI for the active task. */}
|
||||
{isSkeletonPhase && activeTaskId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
|
|
@ -636,7 +637,11 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
transition={{ delay: 0.15 }}
|
||||
className="px-sm"
|
||||
>
|
||||
<SplittingProgressRow chatStore={chatStore} taskId={activeTaskId} />
|
||||
<PlanTaskBox
|
||||
chatStore={chatStore}
|
||||
taskId={activeTaskId}
|
||||
userPrompt={queryGroup.userMessage?.content}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
|||
import { toast } from 'sonner';
|
||||
import BottomBox from './BottomBox';
|
||||
import { ProjectChatContainer } from './ProjectChatContainer';
|
||||
import { PLAN_OVERLAY_SLOT_ID } from './TaskBox/PlanTaskBox';
|
||||
|
||||
/** Minimum scroll padding under messages (matches previous ~8rem floor). */
|
||||
const CHAT_SCROLL_BOTTOM_MIN_PX = 128;
|
||||
|
|
@ -841,43 +842,22 @@ export default function ChatBox(): JSX.Element {
|
|||
if (!chatStore.activeTaskId) return 'input';
|
||||
const task = chatStore.tasks[chatStore.activeTaskId];
|
||||
|
||||
// Queued messages no longer change BottomBox state; QueuedBox renders independently
|
||||
|
||||
// Check for any to_sub_tasks message (confirmed or not)
|
||||
const anyToSubTasksMessage = task.messages.find(
|
||||
(m) => m.step === 'to_sub_tasks'
|
||||
);
|
||||
// The plan-mode splitting UI now lives in PlanTaskBox, not BottomBox.
|
||||
// BottomBox surfaces the action for the unconfirmed plan: `save` if the
|
||||
// user has unsaved subtask edits, otherwise `confirm`.
|
||||
const toSubTasksMessage = task.messages.find(
|
||||
(m) => m.step === 'to_sub_tasks' && !m.isConfirm
|
||||
);
|
||||
|
||||
// Determine if we're in the "splitting in progress" phase (skeleton visible)
|
||||
// Only show splitting if there's NO to_sub_tasks message yet (not even confirmed)
|
||||
// Skip splitting phase when task is already RUNNING (e.g. direct @agent mode)
|
||||
const isSkeletonPhase =
|
||||
(task.status !== ChatTaskStatus.FINISHED &&
|
||||
task.status !== ChatTaskStatus.RUNNING &&
|
||||
!anyToSubTasksMessage &&
|
||||
!task.hasWaitComfirm &&
|
||||
task.messages.length > 0) ||
|
||||
(task.isTakeControl && !anyToSubTasksMessage);
|
||||
if (isSkeletonPhase) {
|
||||
return 'splitting';
|
||||
}
|
||||
|
||||
// After splitting completes and TaskCard is awaiting user confirmation,
|
||||
// the Task becomes 'pending' and we show the confirm state.
|
||||
if (
|
||||
toSubTasksMessage &&
|
||||
!toSubTasksMessage.isConfirm &&
|
||||
task.status === 'pending'
|
||||
) {
|
||||
return 'confirm';
|
||||
return task.planDirty ? 'save' : 'confirm';
|
||||
}
|
||||
|
||||
// If subtasks exist but not yet confirmed while task is still running, keep showing splitting
|
||||
if (toSubTasksMessage && !toSubTasksMessage.isConfirm) {
|
||||
return 'splitting';
|
||||
return task.planDirty ? 'save' : 'confirm';
|
||||
}
|
||||
|
||||
// Check task status
|
||||
|
|
@ -941,10 +921,10 @@ export default function ChatBox(): JSX.Element {
|
|||
const chatColumn = (
|
||||
<>
|
||||
{/* Main: scroll (scrollbar on panel edge) + BottomBox overlay when chatting */}
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 min-w-0 relative flex flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="scrollbar-always-visible min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
|
||||
className="scrollbar-always-visible min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
{hasAnyMessages ? (
|
||||
<ProjectChatContainer
|
||||
|
|
@ -954,8 +934,8 @@ export default function ChatBox(): JSX.Element {
|
|||
isPauseResumeLoading={isPauseResumeLoading}
|
||||
/>
|
||||
) : (
|
||||
<div className="mx-auto flex min-h-full w-full max-w-[600px] flex-col pl-4 pr-2">
|
||||
<div className="flex flex-1 flex-col items-center justify-end gap-1 pb-4"></div>
|
||||
<div className="pl-4 pr-2 mx-auto flex min-h-full w-full max-w-[600px] flex-col">
|
||||
<div className="gap-1 pb-4 flex flex-1 flex-col items-center justify-end"></div>
|
||||
|
||||
{chatStore.activeTaskId && (
|
||||
<BottomBox
|
||||
|
|
@ -994,12 +974,16 @@ export default function ChatBox(): JSX.Element {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{chatStore.activeTaskId && hasAnyMessages && (
|
||||
<div id={PLAN_OVERLAY_SLOT_ID} className="contents" />
|
||||
)}
|
||||
{chatStore.activeTaskId && hasAnyMessages && (
|
||||
<div
|
||||
ref={bottomBoxOverlayRef}
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-30 flex justify-center"
|
||||
data-bottom-box-overlay
|
||||
className="inset-x-0 bottom-0 pointer-events-none absolute z-30 flex justify-center"
|
||||
>
|
||||
<div className="pointer-events-auto w-full max-w-[600px] px-sm">
|
||||
<div className="px-sm pointer-events-auto w-full max-w-[600px]">
|
||||
<BottomBox
|
||||
state={getBottomBoxState()}
|
||||
queuedMessages={queuedMessages}
|
||||
|
|
@ -1007,7 +991,8 @@ export default function ChatBox(): JSX.Element {
|
|||
noModelOverlay={!hasModel}
|
||||
onSelectModel={handleSelectModel}
|
||||
subtitle={
|
||||
getBottomBoxState() === 'confirm'
|
||||
getBottomBoxState() === 'confirm' ||
|
||||
getBottomBoxState() === 'save'
|
||||
? (() => {
|
||||
const messages =
|
||||
chatStore.tasks[chatStore.activeTaskId]?.messages ||
|
||||
|
|
@ -1024,6 +1009,13 @@ export default function ChatBox(): JSX.Element {
|
|||
: chatStore.tasks[chatStore.activeTaskId]?.summaryTask
|
||||
}
|
||||
onStartTask={() => handleConfirmTask()}
|
||||
onSavePlan={async () => {
|
||||
if (chatStore.activeTaskId) {
|
||||
setLoading(true);
|
||||
await chatStore.savePlan(chatStore.activeTaskId);
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
onEdit={handleEditQuery}
|
||||
taskTime={taskTime}
|
||||
taskStatus={chatStore.tasks[chatStore.activeTaskId]?.status}
|
||||
|
|
@ -1064,7 +1056,7 @@ export default function ChatBox(): JSX.Element {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 relative flex h-full w-full flex-1 flex-col overflow-hidden">
|
||||
{chatColumn}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { cn } from '@/lib/utils';
|
|||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
import { TaskStatus } from '@/types/constants';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
function isDone(task: TaskInfo) {
|
||||
return task.status === TaskStatus.COMPLETED;
|
||||
|
|
@ -34,14 +35,18 @@ interface ProgressSectionProps {
|
|||
}
|
||||
|
||||
export function ProgressSection({ title, subtasks }: ProgressSectionProps) {
|
||||
const count = subtasks.length;
|
||||
const visibleSubtasks = useMemo(
|
||||
() => subtasks.filter((task) => task.content.trim() !== ''),
|
||||
[subtasks]
|
||||
);
|
||||
const count = visibleSubtasks.length;
|
||||
const requestTaskBoxFocus = usePageTabStore((s) => s.requestTaskBoxFocus);
|
||||
|
||||
const collapsedStrip =
|
||||
count > 0 ? (
|
||||
<div className="gap-1 min-w-0 mx-1 flex items-center overflow-hidden">
|
||||
<AnimatePresence initial={false}>
|
||||
{subtasks.map((task, idx) => (
|
||||
{visibleSubtasks.map((task, idx) => (
|
||||
<motion.span
|
||||
key={task.id}
|
||||
layout
|
||||
|
|
@ -52,7 +57,7 @@ export function ProgressSection({ title, subtasks }: ProgressSectionProps) {
|
|||
className="gap-1 min-w-0 flex items-center"
|
||||
>
|
||||
<ProgressCircle done={isDone(task)} />
|
||||
{idx < subtasks.length - 1 ? <ProgressConnector /> : null}
|
||||
{idx < visibleSubtasks.length - 1 ? <ProgressConnector /> : null}
|
||||
</motion.span>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
|
@ -78,7 +83,7 @@ export function ProgressSection({ title, subtasks }: ProgressSectionProps) {
|
|||
return (
|
||||
<motion.ul layout className="p-0 m-0 space-y-0.5 list-none">
|
||||
<AnimatePresence initial={false}>
|
||||
{subtasks.map((task) => (
|
||||
{visibleSubtasks.map((task) => (
|
||||
<motion.li
|
||||
key={task.id}
|
||||
layout
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@
|
|||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { StreamingTaskList } from '@/components/ChatBox/TaskBox/StreamingTaskList';
|
||||
import { PlanTaskBox } from '@/components/ChatBox/TaskBox/PlanTaskBox';
|
||||
import { isPlanSplittingPhase } from '@/components/ChatBox/TaskBox/PlanTaskBox/utils';
|
||||
import { TaskCard } from '@/components/ChatBox/TaskBox/TaskCard';
|
||||
import { TypeCardSkeleton } from '@/components/ChatBox/TaskBox/TypeCardSkeleton';
|
||||
import { BASE_WORKFLOW_AGENTS } from '@/components/WorkFlow/baseWorkers';
|
||||
import {
|
||||
FoldedAgentCard,
|
||||
|
|
@ -164,91 +164,79 @@ export default function FoldedPanel({
|
|||
? (projectStore.projects[activeProjectId].activeChatId ?? undefined)
|
||||
: undefined;
|
||||
|
||||
const { taskCardVisible, isSkeletonPhase, showStreamingDecompose, taskType } =
|
||||
useMemo(() => {
|
||||
const fallback = {
|
||||
taskCardVisible: false,
|
||||
isSkeletonPhase: false,
|
||||
showStreamingDecompose: false,
|
||||
taskType: 1 as 1 | 2 | 3,
|
||||
};
|
||||
if (!activeTask || !activeTaskId) return fallback;
|
||||
const { taskCardVisible, taskType } = useMemo(() => {
|
||||
const fallback = {
|
||||
taskCardVisible: false,
|
||||
taskType: 1 as 1 | 2 | 3,
|
||||
};
|
||||
if (!activeTask || !activeTaskId) return fallback;
|
||||
|
||||
const messages = activeTask.messages;
|
||||
const isHumanReply =
|
||||
!!activeTask.activeAsk ||
|
||||
(() => {
|
||||
const userMessages = messages.filter((m: any) => m.role === 'user');
|
||||
const lastUser = userMessages[userMessages.length - 1];
|
||||
if (!lastUser) return false;
|
||||
const userMessageIndex = messages.findIndex(
|
||||
(m: any) => m.id === lastUser.id
|
||||
const messages = activeTask.messages;
|
||||
const isHumanReply =
|
||||
!!activeTask.activeAsk ||
|
||||
(() => {
|
||||
const userMessages = messages.filter((m: any) => m.role === 'user');
|
||||
const lastUser = userMessages[userMessages.length - 1];
|
||||
if (!lastUser) return false;
|
||||
const userMessageIndex = messages.findIndex(
|
||||
(m: any) => m.id === lastUser.id
|
||||
);
|
||||
if (userMessageIndex > 0) {
|
||||
const prevMessage = messages[userMessageIndex - 1];
|
||||
return (
|
||||
prevMessage?.role === 'agent' && prevMessage?.step === AgentStep.ASK
|
||||
);
|
||||
if (userMessageIndex > 0) {
|
||||
const prevMessage = messages[userMessageIndex - 1];
|
||||
return (
|
||||
prevMessage?.role === 'agent' &&
|
||||
prevMessage?.step === AgentStep.ASK
|
||||
);
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
const anyToSubTasksMessage = messages.find(
|
||||
(m: any) => m.step === AgentStep.TO_SUB_TASKS
|
||||
);
|
||||
const isSkeletonPhaseLocal =
|
||||
(activeTask.status !== ChatTaskStatus.FINISHED &&
|
||||
activeTask.status !== ChatTaskStatus.RUNNING &&
|
||||
!anyToSubTasksMessage &&
|
||||
!activeTask.hasWaitComfirm &&
|
||||
messages.length > 0) ||
|
||||
(!!activeTask.isTakeControl && !anyToSubTasksMessage);
|
||||
|
||||
let lastUserIndex = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'user') {
|
||||
lastUserIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const afterLastUser =
|
||||
lastUserIndex >= 0 ? messages.slice(lastUserIndex + 1) : [];
|
||||
const hasTaskPlanForCurrentTurn = afterLastUser.some(
|
||||
(m: any) => m.step === AgentStep.TO_SUB_TASKS
|
||||
);
|
||||
return false;
|
||||
})();
|
||||
|
||||
const isDecomposing = streamingDecomposeText.length > 0;
|
||||
const shouldShowFallbackTask =
|
||||
lastUserIndex >= 0 &&
|
||||
!hasTaskPlanForCurrentTurn &&
|
||||
const anyToSubTasksMessage = messages.find(
|
||||
(m: any) => m.step === AgentStep.TO_SUB_TASKS
|
||||
);
|
||||
const isSkeletonPhaseLocal =
|
||||
(activeTask.status !== ChatTaskStatus.FINISHED &&
|
||||
activeTask.status !== ChatTaskStatus.RUNNING &&
|
||||
!anyToSubTasksMessage &&
|
||||
!activeTask.hasWaitComfirm &&
|
||||
!isDecomposing &&
|
||||
activeTask.status !== ChatTaskStatus.FINISHED;
|
||||
messages.length > 0) ||
|
||||
(!!activeTask.isTakeControl && !anyToSubTasksMessage);
|
||||
|
||||
const taskLike = hasTaskPlanForCurrentTurn || shouldShowFallbackTask;
|
||||
let lastUserIndex = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'user') {
|
||||
lastUserIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const afterLastUser =
|
||||
lastUserIndex >= 0 ? messages.slice(lastUserIndex + 1) : [];
|
||||
const hasTaskPlanForCurrentTurn = afterLastUser.some(
|
||||
(m: any) => m.step === AgentStep.TO_SUB_TASKS
|
||||
);
|
||||
|
||||
const taskCardVisibleLocal =
|
||||
taskLike && !isSkeletonPhaseLocal && !isHumanReply;
|
||||
const isDecomposing = streamingDecomposeText.length > 0;
|
||||
const shouldShowFallbackTask =
|
||||
lastUserIndex >= 0 &&
|
||||
!hasTaskPlanForCurrentTurn &&
|
||||
!activeTask.hasWaitComfirm &&
|
||||
!isDecomposing &&
|
||||
activeTask.status !== ChatTaskStatus.FINISHED;
|
||||
|
||||
const showStreamingDecomposeLocal =
|
||||
streamingDecomposeText.length > 0 &&
|
||||
activeTask.status !== ChatTaskStatus.FINISHED &&
|
||||
!isHumanReply &&
|
||||
!hasTaskPlanForCurrentTurn;
|
||||
const taskLike = hasTaskPlanForCurrentTurn || shouldShowFallbackTask;
|
||||
|
||||
const toSub = [...messages]
|
||||
.reverse()
|
||||
.find((m: any) => m.step === AgentStep.TO_SUB_TASKS);
|
||||
const taskTypeLocal = (toSub?.taskType as 1 | 2 | 3) || 1;
|
||||
const taskCardVisibleLocal =
|
||||
taskLike && !isSkeletonPhaseLocal && !isHumanReply;
|
||||
|
||||
return {
|
||||
taskCardVisible: taskCardVisibleLocal,
|
||||
isSkeletonPhase: isSkeletonPhaseLocal,
|
||||
showStreamingDecompose: showStreamingDecomposeLocal,
|
||||
taskType: taskTypeLocal,
|
||||
};
|
||||
}, [activeTask, activeTaskId, streamingDecomposeText]);
|
||||
const toSub = [...messages]
|
||||
.reverse()
|
||||
.find((m: any) => m.step === AgentStep.TO_SUB_TASKS);
|
||||
const taskTypeLocal = (toSub?.taskType as 1 | 2 | 3) || 1;
|
||||
|
||||
return {
|
||||
taskCardVisible: taskCardVisibleLocal,
|
||||
taskType: taskTypeLocal,
|
||||
};
|
||||
}, [activeTask, activeTaskId, streamingDecomposeText]);
|
||||
|
||||
const sortedAgents = useMemo(() => {
|
||||
const base = [...BASE_WORKFLOW_AGENTS, ...workerList].filter(
|
||||
|
|
@ -300,6 +288,22 @@ export default function FoldedPanel({
|
|||
activeTask.status === ChatTaskStatus.FINISHED ||
|
||||
activeTask.status === ChatTaskStatus.PAUSE);
|
||||
|
||||
const hasUnconfirmedPlan = Boolean(
|
||||
activeTask?.messages.some(
|
||||
(m: any) => m.step === AgentStep.TO_SUB_TASKS && !m.isConfirm
|
||||
)
|
||||
);
|
||||
const showPlanTaskBox = Boolean(
|
||||
activeTaskId &&
|
||||
activeTask &&
|
||||
activeChatStore &&
|
||||
!activeTask.hasWaitComfirm &&
|
||||
!isMainTaskStarted &&
|
||||
(isPlanSplittingPhase(activeTask) ||
|
||||
streamingDecomposeText.length > 0 ||
|
||||
hasUnconfirmedPlan)
|
||||
);
|
||||
|
||||
/** TaskCard only after the main task has started and an agent is selected (not detail-pane "Select an agent"). */
|
||||
const showFoldedTaskCard =
|
||||
taskCardVisible && isMainTaskStarted && detailAgent != null;
|
||||
|
|
@ -427,53 +431,45 @@ export default function FoldedPanel({
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(showFoldedTaskCard ||
|
||||
isSkeletonPhase ||
|
||||
showStreamingDecompose) &&
|
||||
{showFoldedTaskCard &&
|
||||
activeTaskId &&
|
||||
activeTask &&
|
||||
chatStore && (
|
||||
<div className="scrollbar scrollbar-always-visible min-h-0 min-w-0 pb-2 flex h-full shrink-0 flex-col overflow-x-hidden overflow-y-auto">
|
||||
{showStreamingDecompose && (
|
||||
<StreamingTaskList
|
||||
streamingText={streamingDecomposeText}
|
||||
/>
|
||||
)}
|
||||
{isSkeletonPhase && (
|
||||
<TypeCardSkeleton
|
||||
isTakeControl={activeTask.isTakeControl || false}
|
||||
/>
|
||||
)}
|
||||
{showFoldedTaskCard && (
|
||||
<TaskCard
|
||||
key={`task-folded-${activeTaskId}`}
|
||||
chatId={taskPanelChatId}
|
||||
taskInfo={activeTask.taskInfo || []}
|
||||
taskType={taskType}
|
||||
taskAssigning={activeTask.taskAssigning || []}
|
||||
taskRunning={activeTask.taskRunning || []}
|
||||
progressValue={activeTask.progressValue || 0}
|
||||
summaryTask={activeTask.summaryTask || ''}
|
||||
onAddTask={() => {
|
||||
chatStore.setIsTaskEdit(activeTaskId, true);
|
||||
chatStore.addTaskInfo();
|
||||
}}
|
||||
onUpdateTask={(taskIndex, content) => {
|
||||
chatStore.setIsTaskEdit(activeTaskId, true);
|
||||
chatStore.updateTaskInfo(taskIndex, content);
|
||||
}}
|
||||
onSaveTask={() => {
|
||||
chatStore.saveTaskInfo();
|
||||
}}
|
||||
onDeleteTask={(taskIndex) => {
|
||||
chatStore.setIsTaskEdit(activeTaskId, true);
|
||||
chatStore.deleteTaskInfo(taskIndex);
|
||||
}}
|
||||
clickable
|
||||
/>
|
||||
)}
|
||||
<TaskCard
|
||||
key={`task-folded-${activeTaskId}`}
|
||||
chatId={taskPanelChatId}
|
||||
taskInfo={activeTask.taskInfo || []}
|
||||
taskType={taskType}
|
||||
taskAssigning={activeTask.taskAssigning || []}
|
||||
taskRunning={activeTask.taskRunning || []}
|
||||
progressValue={activeTask.progressValue || 0}
|
||||
summaryTask={activeTask.summaryTask || ''}
|
||||
onAddTask={() => {
|
||||
chatStore.addTaskInfo();
|
||||
}}
|
||||
onUpdateTask={(taskIndex, content) => {
|
||||
chatStore.updateTaskInfo(taskIndex, content);
|
||||
}}
|
||||
onSaveTask={() => {
|
||||
chatStore.saveTaskInfo();
|
||||
}}
|
||||
onDeleteTask={(taskIndex) => {
|
||||
chatStore.deleteTaskInfo(taskIndex);
|
||||
}}
|
||||
clickable
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showPlanTaskBox && activeTaskId && activeChatStore ? (
|
||||
<div className="pb-2 shrink-0">
|
||||
<PlanTaskBox
|
||||
chatStore={activeChatStore}
|
||||
taskId={activeTaskId}
|
||||
allowOverlay={false}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 min-w-0 px-2 pb-2 flex flex-1 flex-col overflow-hidden">
|
||||
{detailAgent ? (
|
||||
<AgentDetailPane
|
||||
|
|
@ -499,6 +495,13 @@ export default function FoldedPanel({
|
|||
transition={FOLDED_LAYOUT_TRANSITION}
|
||||
>
|
||||
<div className="gap-2 min-w-0 flex w-full max-w-full flex-col opacity-80">
|
||||
{showPlanTaskBox && activeTaskId && activeChatStore ? (
|
||||
<PlanTaskBox
|
||||
chatStore={activeChatStore}
|
||||
taskId={activeTaskId}
|
||||
allowOverlay={false}
|
||||
/>
|
||||
) : null}
|
||||
{sortedAgents.map((agent) => (
|
||||
<FoldedAgentCard
|
||||
key={agent.agent_id}
|
||||
|
|
|
|||
|
|
@ -644,20 +644,20 @@ export function Node({ id, data }: NodeProps) {
|
|||
? 'bg-ds-bg-status-pending-subtle-default hover:bg-ds-bg-status-pending-subtle-hover'
|
||||
: 'bg-ds-bg-status-running-subtle-default hover:bg-ds-bg-status-running-subtle-hover';
|
||||
const taskTextClass = task.reAssignTo
|
||||
? 'text-ds-text-status-blocked-default-default'
|
||||
? 'text-ds-text-status-blocked-strong-default'
|
||||
: task.status === TaskStatus.COMPLETED
|
||||
? 'text-ds-text-status-completed-default-default'
|
||||
? 'text-ds-text-status-completed-strong-default'
|
||||
: task.status === TaskStatus.FAILED
|
||||
? 'text-ds-text-status-error-default-default'
|
||||
? 'text-ds-text-status-error-strong-default'
|
||||
: task.status === TaskStatus.RUNNING
|
||||
? 'text-ds-text-status-running-default-default'
|
||||
? 'text-ds-text-status-running-strong-default'
|
||||
: task.status === TaskStatus.BLOCKED
|
||||
? 'text-ds-text-status-blocked-default-default'
|
||||
? 'text-ds-text-status-blocked-strong-default'
|
||||
: task.status === TaskStatus.SKIPPED ||
|
||||
task.status === TaskStatus.WAITING ||
|
||||
task.status === TaskStatus.EMPTY
|
||||
? 'text-ds-text-status-pending-default-default'
|
||||
: 'text-ds-text-status-running-default-default';
|
||||
? 'text-ds-text-status-pending-strong-default'
|
||||
: 'text-ds-text-status-running-strong-default';
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -91,5 +91,10 @@
|
|||
"input-attach-manage-connectors": "إدارة الموصلات",
|
||||
"input-attach-open-browser": "فتح متصفح",
|
||||
"input-attach-manage-browsers": "إدارة المتصفحات",
|
||||
"input-attach-menu-trigger": "إضافة ملفات أو صور، أو فتح المهارات أو الموصلات أو المتصفح من القائمة"
|
||||
"input-attach-menu-trigger": "إضافة ملفات أو صور، أو فتح المهارات أو الموصلات أو المتصفح من القائمة",
|
||||
"subtasks-planning": "تخطيط المهام الفرعية",
|
||||
"expand-plan": "توسيع الخطة",
|
||||
"minimize-plan": "تصغير الخطة",
|
||||
"expand-subtasks": "توسيع المهام الفرعية",
|
||||
"add-subtask-placeholder": "أضف مهمة فرعية جديدة واضغط Enter لإضافة المزيد"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,5 +91,10 @@
|
|||
"input-attach-manage-connectors": "Connectors verwalten",
|
||||
"input-attach-open-browser": "Browser öffnen",
|
||||
"input-attach-manage-browsers": "Browser verwalten",
|
||||
"input-attach-menu-trigger": "Dateien oder Fotos hinzufügen oder Skills, Connectors oder Browser über das Menü öffnen"
|
||||
"input-attach-menu-trigger": "Dateien oder Fotos hinzufügen oder Skills, Connectors oder Browser über das Menü öffnen",
|
||||
"subtasks-planning": "Teilaufgabenplanung",
|
||||
"expand-plan": "Plan erweitern",
|
||||
"minimize-plan": "Plan minimieren",
|
||||
"expand-subtasks": "Teilaufgaben erweitern",
|
||||
"add-subtask-placeholder": "Neue Teilaufgabe hinzufügen und Enter drücken, um weitere hinzuzufügen"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,5 +91,10 @@
|
|||
"input-attach-manage-connectors": "Manage connectors",
|
||||
"input-attach-open-browser": "Open a browser",
|
||||
"input-attach-manage-browsers": "Manage browsers",
|
||||
"input-attach-menu-trigger": "Add files or photos, or open Skills, Connectors, or Browser from the menu"
|
||||
"input-attach-menu-trigger": "Add files or photos, or open Skills, Connectors, or Browser from the menu",
|
||||
"subtasks-planning": "Subtasks Planning",
|
||||
"expand-plan": "Expand plan",
|
||||
"minimize-plan": "Minimize plan",
|
||||
"expand-subtasks": "Expand subtasks",
|
||||
"add-subtask-placeholder": "Add a new subtask and press Enter to add more"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,5 +91,10 @@
|
|||
"input-attach-manage-connectors": "Gestionar conectores",
|
||||
"input-attach-open-browser": "Abrir un navegador",
|
||||
"input-attach-manage-browsers": "Gestionar navegadores",
|
||||
"input-attach-menu-trigger": "Añadir archivos o fotos o abrir Habilidades, Conectores o Navegador desde el menú"
|
||||
"input-attach-menu-trigger": "Añadir archivos o fotos o abrir Habilidades, Conectores o Navegador desde el menú",
|
||||
"subtasks-planning": "Planificación de subtareas",
|
||||
"expand-plan": "Expandir plan",
|
||||
"minimize-plan": "Minimizar plan",
|
||||
"expand-subtasks": "Expandir subtareas",
|
||||
"add-subtask-placeholder": "Añade una nueva subtarea y pulsa Enter para añadir más"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,5 +91,10 @@
|
|||
"input-attach-manage-connectors": "Gérer les connecteurs",
|
||||
"input-attach-open-browser": "Ouvrir un navigateur",
|
||||
"input-attach-manage-browsers": "Gérer les navigateurs",
|
||||
"input-attach-menu-trigger": "Ajouter fichiers ou photos ou ouvrir Compétences, Connecteurs ou Navigateur depuis le menu"
|
||||
"input-attach-menu-trigger": "Ajouter fichiers ou photos ou ouvrir Compétences, Connecteurs ou Navigateur depuis le menu",
|
||||
"subtasks-planning": "Planification des sous-tâches",
|
||||
"expand-plan": "Développer le plan",
|
||||
"minimize-plan": "Réduire le plan",
|
||||
"expand-subtasks": "Développer les sous-tâches",
|
||||
"add-subtask-placeholder": "Ajoutez une nouvelle sous-tâche et appuyez sur Entrée pour en ajouter d’autres"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,5 +91,10 @@
|
|||
"input-attach-manage-connectors": "Gestisci i connettori",
|
||||
"input-attach-open-browser": "Apri un browser",
|
||||
"input-attach-manage-browsers": "Gestisci i browser",
|
||||
"input-attach-menu-trigger": "Aggiungi file o foto o apri Skill, Connettori o Browser dal menu"
|
||||
"input-attach-menu-trigger": "Aggiungi file o foto o apri Skill, Connettori o Browser dal menu",
|
||||
"subtasks-planning": "Pianificazione delle sottoattività",
|
||||
"expand-plan": "Espandi piano",
|
||||
"minimize-plan": "Riduci piano",
|
||||
"expand-subtasks": "Espandi sottoattività",
|
||||
"add-subtask-placeholder": "Aggiungi una nuova sottoattività e premi Invio per aggiungerne altre"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,5 +91,10 @@
|
|||
"input-attach-manage-connectors": "コネクタを管理",
|
||||
"input-attach-open-browser": "ブラウザを開く",
|
||||
"input-attach-manage-browsers": "ブラウザを管理",
|
||||
"input-attach-menu-trigger": "ファイル・写真の追加、またはメニューからスキル・コネクタ・ブラウザを開く"
|
||||
"input-attach-menu-trigger": "ファイル・写真の追加、またはメニューからスキル・コネクタ・ブラウザを開く",
|
||||
"subtasks-planning": "サブタスクの計画",
|
||||
"expand-plan": "計画を展開",
|
||||
"minimize-plan": "計画を最小化",
|
||||
"expand-subtasks": "サブタスクを展開",
|
||||
"add-subtask-placeholder": "新しいサブタスクを追加し、Enter キーでさらに追加"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,5 +91,10 @@
|
|||
"input-attach-manage-connectors": "커넥터 관리",
|
||||
"input-attach-open-browser": "브라우저 열기",
|
||||
"input-attach-manage-browsers": "브라우저 관리",
|
||||
"input-attach-menu-trigger": "파일·사진 추가 또는 메뉴에서 스킬, 커넥터, 브라우저 열기"
|
||||
"input-attach-menu-trigger": "파일·사진 추가 또는 메뉴에서 스킬, 커넥터, 브라우저 열기",
|
||||
"subtasks-planning": "하위 작업 계획",
|
||||
"expand-plan": "계획 펼치기",
|
||||
"minimize-plan": "계획 최소화",
|
||||
"expand-subtasks": "하위 작업 펼치기",
|
||||
"add-subtask-placeholder": "새 하위 작업을 추가하고 Enter를 눌러 더 추가하세요"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,5 +91,10 @@
|
|||
"input-attach-manage-connectors": "Управлять коннекторами",
|
||||
"input-attach-open-browser": "Открыть браузер",
|
||||
"input-attach-manage-browsers": "Управлять браузерами",
|
||||
"input-attach-menu-trigger": "Добавить файлы или фото или открыть Навыки, Коннекторы или Браузер из меню"
|
||||
"input-attach-menu-trigger": "Добавить файлы или фото или открыть Навыки, Коннекторы или Браузер из меню",
|
||||
"subtasks-planning": "Планирование подзадач",
|
||||
"expand-plan": "Развернуть план",
|
||||
"minimize-plan": "Свернуть план",
|
||||
"expand-subtasks": "Развернуть подзадачи",
|
||||
"add-subtask-placeholder": "Добавьте новую подзадачу и нажмите Enter, чтобы добавить еще"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,5 +91,10 @@
|
|||
"input-attach-manage-connectors": "管理连接器",
|
||||
"input-attach-open-browser": "打开浏览器",
|
||||
"input-attach-manage-browsers": "管理浏览器",
|
||||
"input-attach-menu-trigger": "添加文件、照片,或通过菜单打开技能、连接器、浏览器"
|
||||
"input-attach-menu-trigger": "添加文件、照片,或通过菜单打开技能、连接器、浏览器",
|
||||
"subtasks-planning": "子任务规划",
|
||||
"expand-plan": "展开计划",
|
||||
"minimize-plan": "最小化计划",
|
||||
"expand-subtasks": "展开子任务",
|
||||
"add-subtask-placeholder": "添加新的子任务,按 Enter 添加更多"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,5 +91,10 @@
|
|||
"input-attach-manage-connectors": "管理連接器",
|
||||
"input-attach-open-browser": "開啟瀏覽器",
|
||||
"input-attach-manage-browsers": "管理瀏覽器",
|
||||
"input-attach-menu-trigger": "新增檔案、相片,或從選單開啟技能、連接器、瀏覽器"
|
||||
"input-attach-menu-trigger": "新增檔案、相片,或從選單開啟技能、連接器、瀏覽器",
|
||||
"subtasks-planning": "子任務規劃",
|
||||
"expand-plan": "展開計劃",
|
||||
"minimize-plan": "最小化計劃",
|
||||
"expand-subtasks": "展開子任務",
|
||||
"add-subtask-placeholder": "新增子任務,按 Enter 新增更多"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,17 +40,20 @@ function getTaskInfoRows(
|
|||
return Array.isArray(task.taskInfo) ? task.taskInfo : [];
|
||||
}
|
||||
|
||||
export function getBottomBoxStateForTask(
|
||||
task: TaskLifecycleFields
|
||||
): BottomBoxState {
|
||||
/**
|
||||
* `'splitting'` is no longer a real `BottomBoxState` — the splitting visuals
|
||||
* moved into `PlanTaskBox`. For the sidebar/project list we still need a way to
|
||||
* recognize the pre-confirm planning window, so we compute that signal locally
|
||||
* via {@link isTaskInPlanPhase}.
|
||||
*/
|
||||
function isTaskInPlanPhase(task: TaskLifecycleFields): boolean {
|
||||
const messages = getTaskMessages(task);
|
||||
const status = task.status ?? ChatTaskStatus.PENDING;
|
||||
const type = task.type ?? '';
|
||||
const hasWaitComfirm = Boolean(task.hasWaitComfirm);
|
||||
const isTakeControl = Boolean(task.isTakeControl);
|
||||
|
||||
const anyToSubTasksMessage = messages.find((m) => m.step === 'to_sub_tasks');
|
||||
const toSubTasksMessage = messages.find(
|
||||
const unconfirmedToSubTasks = messages.find(
|
||||
(m) => m.step === 'to_sub_tasks' && !m.isConfirm
|
||||
);
|
||||
|
||||
|
|
@ -60,20 +63,23 @@ export function getBottomBoxStateForTask(
|
|||
!hasWaitComfirm &&
|
||||
messages.length > 0) ||
|
||||
(isTakeControl && !anyToSubTasksMessage);
|
||||
if (isSkeletonPhase) {
|
||||
return 'splitting';
|
||||
}
|
||||
|
||||
if (
|
||||
toSubTasksMessage &&
|
||||
!toSubTasksMessage.isConfirm &&
|
||||
status === 'pending'
|
||||
) {
|
||||
return 'confirm';
|
||||
}
|
||||
return isSkeletonPhase || Boolean(unconfirmedToSubTasks);
|
||||
}
|
||||
|
||||
export function getBottomBoxStateForTask(
|
||||
task: TaskLifecycleFields
|
||||
): BottomBoxState {
|
||||
const messages = getTaskMessages(task);
|
||||
const status = task.status ?? ChatTaskStatus.PENDING;
|
||||
const type = task.type ?? '';
|
||||
|
||||
const toSubTasksMessage = messages.find(
|
||||
(m) => m.step === 'to_sub_tasks' && !m.isConfirm
|
||||
);
|
||||
|
||||
if (toSubTasksMessage && !toSubTasksMessage.isConfirm) {
|
||||
return 'splitting';
|
||||
return 'confirm';
|
||||
}
|
||||
|
||||
if (status === ChatTaskStatus.RUNNING || status === ChatTaskStatus.PAUSE) {
|
||||
|
|
@ -93,9 +99,10 @@ export type TaskListShelfTone = 'splitting' | 'running' | 'default';
|
|||
export function getTaskListShelfTone(
|
||||
task: TaskLifecycleFields
|
||||
): TaskListShelfTone {
|
||||
if (isTaskInPlanPhase(task)) return 'splitting';
|
||||
const s = getBottomBoxStateForTask(task);
|
||||
if (s === 'running') return 'running';
|
||||
if (s === 'splitting' || s === 'confirm') return 'splitting';
|
||||
if (s === 'confirm') return 'splitting';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ interface Task {
|
|||
snapshots: any[];
|
||||
snapshotsTemp: any[];
|
||||
isTakeControl: boolean;
|
||||
isTaskEdit: boolean;
|
||||
planDirty: boolean;
|
||||
isContextExceeded?: boolean;
|
||||
// Streaming decompose text - stored separately to avoid frequent re-renders
|
||||
streamingDecomposeText: string;
|
||||
|
|
@ -478,7 +478,8 @@ export interface ChatStore {
|
|||
setSnapshots: (taskId: string, snapshots: any[]) => void;
|
||||
setIsTakeControl: (taskId: string, isTakeControl: boolean) => void;
|
||||
setSnapshotsTemp: (taskId: string, snapshot: any) => void;
|
||||
setIsTaskEdit: (taskId: string, isTaskEdit: boolean) => void;
|
||||
setPlanDirty: (taskId: string, dirty: boolean) => void;
|
||||
savePlan: (taskId: string) => Promise<void>;
|
||||
clearTasks: () => void;
|
||||
setIsContextExceeded: (taskId: string, isContextExceeded: boolean) => void;
|
||||
setNextTaskId: (taskId: string | null) => void;
|
||||
|
|
@ -688,14 +689,12 @@ const normalizeToolkitMessage = (value: unknown) => {
|
|||
};
|
||||
|
||||
/** Persist subtask edits to backend via PUT /task/{project_id}. */
|
||||
const persistSubtaskEdits = (taskInfo: TaskInfo[]) => {
|
||||
const persistSubtaskEdits = async (taskInfo: TaskInfo[]) => {
|
||||
const projectId = useProjectStore.getState().activeProjectId;
|
||||
if (!projectId) return;
|
||||
|
||||
const nonEmpty = taskInfo.filter((t) => t.content !== '');
|
||||
fetchPut(`/task/${projectId}`, { task: nonEmpty }).catch((err) =>
|
||||
console.error('Failed to persist subtask edits:', err)
|
||||
);
|
||||
await fetchPut(`/task/${projectId}`, { task: nonEmpty });
|
||||
};
|
||||
|
||||
const resolveProcessTaskIdForToolkitEvent = (
|
||||
|
|
@ -849,7 +848,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
|
|||
snapshots: [],
|
||||
snapshotsTemp: [],
|
||||
isTakeControl: false,
|
||||
isTaskEdit: false,
|
||||
planDirty: false,
|
||||
streamingDecomposeText: '',
|
||||
executionId: undefined,
|
||||
},
|
||||
|
|
@ -1599,7 +1598,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
|
|||
setIsContextExceeded,
|
||||
setStreamingDecomposeText,
|
||||
clearStreamingDecomposeText,
|
||||
setIsTaskEdit,
|
||||
setPlanDirty,
|
||||
} = getCurrentChatStore();
|
||||
|
||||
currentTaskId = getCurrentTaskId();
|
||||
|
|
@ -1680,7 +1679,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
|
|||
}
|
||||
|
||||
// Each splitting round starts in a clean editing state
|
||||
setIsTaskEdit(currentTaskId, false);
|
||||
setPlanDirty(currentTaskId, false);
|
||||
|
||||
const messages = [...tasks[currentTaskId].messages];
|
||||
const toSubTaskIndex = messages.findLastIndex(
|
||||
|
|
@ -1705,7 +1704,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
|
|||
try {
|
||||
const currentStore = getCurrentChatStore();
|
||||
const currentId = getCurrentTaskId();
|
||||
const { tasks, handleConfirmTask, setIsTaskEdit } =
|
||||
const { tasks, handleConfirmTask, setPlanDirty } =
|
||||
currentStore;
|
||||
const message = tasks[currentId].messages.findLast(
|
||||
(item) => item.step === AgentStep.TO_SUB_TASKS
|
||||
|
|
@ -1717,11 +1716,11 @@ const chatStore = (initial?: Partial<ChatStore>) =>
|
|||
project_id &&
|
||||
!isConfirm &&
|
||||
!isTakeControl &&
|
||||
!tasks[currentId].isTaskEdit
|
||||
!tasks[currentId].planDirty
|
||||
) {
|
||||
handleConfirmTask(project_id, currentId, type);
|
||||
}
|
||||
setIsTaskEdit(currentId, false);
|
||||
setPlanDirty(currentId, false);
|
||||
delete autoConfirmTimers[currentId];
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
|
@ -3437,7 +3436,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
|
|||
setTaskTime,
|
||||
setTaskInfo,
|
||||
setTaskRunning,
|
||||
setIsTaskEdit,
|
||||
setPlanDirty,
|
||||
} = get();
|
||||
if (!taskId) return;
|
||||
|
||||
|
|
@ -3493,7 +3492,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
|
|||
}
|
||||
|
||||
// Reset editing state after manual confirmation so next round can auto-start
|
||||
setIsTaskEdit(taskId, false);
|
||||
setPlanDirty(taskId, false);
|
||||
},
|
||||
addTaskInfo() {
|
||||
const { tasks, activeTaskId, setTaskInfo } = get();
|
||||
|
|
@ -3682,7 +3681,6 @@ const chatStore = (initial?: Partial<ChatStore>) =>
|
|||
const targetTaskInfo = [...tasks[activeTaskId].taskInfo];
|
||||
targetTaskInfo.splice(index, 1);
|
||||
setTaskInfo(activeTaskId, targetTaskInfo);
|
||||
persistSubtaskEdits(targetTaskInfo);
|
||||
},
|
||||
getLastUserMessage() {
|
||||
const { activeTaskId, tasks } = get();
|
||||
|
|
@ -3831,18 +3829,78 @@ const chatStore = (initial?: Partial<ChatStore>) =>
|
|||
};
|
||||
});
|
||||
},
|
||||
setIsTaskEdit(taskId: string, isTaskEdit: boolean) {
|
||||
setPlanDirty(taskId: string, dirty: boolean) {
|
||||
set((state) => ({
|
||||
...state,
|
||||
tasks: {
|
||||
...state.tasks,
|
||||
[taskId]: {
|
||||
...state.tasks[taskId],
|
||||
isTaskEdit,
|
||||
planDirty: dirty,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
async savePlan(taskId: string) {
|
||||
const { tasks, setPlanDirty } = get();
|
||||
const task = tasks[taskId];
|
||||
if (!task) return;
|
||||
try {
|
||||
await persistSubtaskEdits(task.taskInfo);
|
||||
setPlanDirty(taskId, false);
|
||||
} catch (err) {
|
||||
console.error('Failed to persist subtask edits:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
// After Save, restart the 30-second auto-confirm timer for predictable UX.
|
||||
const projectId = useProjectStore.getState().activeProjectId;
|
||||
const lastToSubTasks = task.messages.findLast(
|
||||
(m: Message) => m.step === AgentStep.TO_SUB_TASKS
|
||||
);
|
||||
if (
|
||||
!projectId ||
|
||||
!lastToSubTasks ||
|
||||
lastToSubTasks.isConfirm ||
|
||||
task.isTakeControl
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (autoConfirmTimers[taskId]) {
|
||||
clearTimeout(autoConfirmTimers[taskId]);
|
||||
delete autoConfirmTimers[taskId];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error clearing auto-confirm timer in savePlan:', error);
|
||||
}
|
||||
|
||||
autoConfirmTimers[taskId] = setTimeout(() => {
|
||||
try {
|
||||
const latestState = get();
|
||||
const latest = latestState.tasks[taskId];
|
||||
if (!latest) {
|
||||
delete autoConfirmTimers[taskId];
|
||||
return;
|
||||
}
|
||||
const message = latest.messages.findLast(
|
||||
(item: Message) => item.step === AgentStep.TO_SUB_TASKS
|
||||
);
|
||||
const isConfirm = message?.isConfirm || false;
|
||||
const isTakeControl = latest.isTakeControl;
|
||||
|
||||
if (projectId && !isConfirm && !isTakeControl && !latest.planDirty) {
|
||||
latestState.handleConfirmTask(projectId, taskId);
|
||||
}
|
||||
latestState.setPlanDirty(taskId, false);
|
||||
delete autoConfirmTimers[taskId];
|
||||
} catch (error) {
|
||||
console.error('Error in savePlan auto-confirm handler:', error);
|
||||
delete autoConfirmTimers[taskId];
|
||||
}
|
||||
}, 30000);
|
||||
},
|
||||
clearTasks: () => {
|
||||
const { create } = get();
|
||||
console.log('clearTasks');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue