diff --git a/src/components/ChatBox/BottomBox/BoxHeader.tsx b/src/components/ChatBox/BottomBox/BoxHeader.tsx index 4b20e222..ebffec5a 100644 --- a/src/components/ChatBox/BottomBox/BoxHeader.tsx +++ b/src/components/ChatBox/BottomBox/BoxHeader.tsx @@ -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 ( -
-
- - -
- - {t('chat.splitting-tasks')} - -
-
-
- ); -}; - /** * Variant: Confirm */ @@ -83,11 +39,11 @@ export const BoxHeaderConfirm = ({ return (
-
+
-
- {subtitle ? ( -
- -
- ) : null} -
- + + +
+
+ ); +}; diff --git a/src/components/ChatBox/BottomBox/index.tsx b/src/components/ChatBox/BottomBox/index.tsx index d72b1573..3fc37376 100644 --- a/src/components/ChatBox/BottomBox/index.tsx +++ b/src/components/ChatBox/BottomBox/index.tsx @@ -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' && } {state === 'confirm' && ( )} + {state === 'save' && ( + + )} {/* Inputbox (always visible) */} diff --git a/src/components/ChatBox/MessageItem/SplittingProgressRow.tsx b/src/components/ChatBox/MessageItem/SplittingProgressRow.tsx deleted file mode 100644 index 44562ab8..00000000 --- a/src/components/ChatBox/MessageItem/SplittingProgressRow.tsx +++ /dev/null @@ -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(); - -/** 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 ( -
-
- -
- - {t('chat.splitting-tasks')} - - - {formatSplittingElapsed(elapsedMs)} - - - {' '} - •{' '} - - - - tokens - -
- ); -} diff --git a/src/components/ChatBox/TaskBox/PlanTaskBox/ExpandedOverlay.tsx b/src/components/ChatBox/TaskBox/PlanTaskBox/ExpandedOverlay.tsx new file mode 100644 index 00000000..14c3f140 --- /dev/null +++ b/src/components/ChatBox/TaskBox/PlanTaskBox/ExpandedOverlay.tsx @@ -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 ( + +
+
+
+
+ {t('chat.subtasks-planning')} +
+ +
+ + {userPrompt ? ( +
+ +
+ ) : null} + + {isSplitting && ( +
+ +
+ )} + +
+ {hasTaskInfo ? ( + + ) : ( +
+ {streamingTasks.tasks.map((content, i) => { + const isLast = i === streamingTasks.tasks.length - 1; + const streaming = isLast && streamingTasks.isStreaming; + return ( +
+
+ {streaming ? ( + + ) : ( + + )} +
+ + {content} + +
+ ); + })} +
+ )} +
+
+
+
+ ); +} diff --git a/src/components/ChatBox/TaskBox/PlanTaskBox/FoldedView.tsx b/src/components/ChatBox/TaskBox/PlanTaskBox/FoldedView.tsx new file mode 100644 index 00000000..d903e382 --- /dev/null +++ b/src/components/ChatBox/TaskBox/PlanTaskBox/FoldedView.tsx @@ -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 ( + +
+
+ {summaryTask || t('chat.subtasks-planning')} +
+ {canExpand ? ( + + ) : null} +
+ + {isSplitting && !showPreview && ( +
+ +
+ )} + + {showPreview && ( +
+
+ {previewRows.map((row) => ( +
+
+ {row.streaming ? ( + + ) : ( + + )} +
+ + {row.content} + +
+ ))} +
+ {canExpand ? ( +
+ +
+ ) : null} +
+ )} +
+ ); +} diff --git a/src/components/ChatBox/TaskBox/PlanTaskBox/StatusRow.tsx b/src/components/ChatBox/TaskBox/PlanTaskBox/StatusRow.tsx new file mode 100644 index 00000000..80284862 --- /dev/null +++ b/src/components/ChatBox/TaskBox/PlanTaskBox/StatusRow.tsx @@ -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 ( +
+ + + + + {t('chat.splitting-tasks')} + + + {formatSplittingElapsed(elapsedMs)} + + + • + + + + tokens + +
+ ); +} diff --git a/src/components/ChatBox/TaskBox/PlanTaskBox/SubtaskEditor.tsx b/src/components/ChatBox/TaskBox/PlanTaskBox/SubtaskEditor.tsx new file mode 100644 index 00000000..0cdb7531 --- /dev/null +++ b/src/components/ChatBox/TaskBox/PlanTaskBox/SubtaskEditor.tsx @@ -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>([]); + const [focusIndex, setFocusIndex] = useState(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, + 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 ( +
+ {taskInfo.map((task, index) => ( +
+
+ +
+