refactor spliting task ux and ui (#1620)

This commit is contained in:
Douglas Lai 2026-05-07 19:49:03 +01:00 committed by GitHub
parent 9d06ad0b0e
commit 29ecdb6267
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1364 additions and 749 deletions

View file

@ -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>
);
};

View file

@ -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} />

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
</>
);
}

View 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;

View file

@ -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>
);
}

View file

@ -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>
);
};

View file

@ -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 / nonsingle-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>

View file

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

View file

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

View file

@ -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}

View file

@ -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={() => {

View file

@ -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 لإضافة المزيد"
}

View file

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

View file

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

View file

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

View file

@ -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 dautres"
}

View file

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

View file

@ -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 キーでさらに追加"
}

View file

@ -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를 눌러 더 추가하세요"
}

View file

@ -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, чтобы добавить еще"
}

View file

@ -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 添加更多"
}

View file

@ -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 新增更多"
}

View file

@ -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';
}

View file

@ -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');