eigent/src/components/Project/HeaderBox/ChatTimeline.tsx
2026-06-17 16:31:01 +01:00

253 lines
7.7 KiB
TypeScript

// ========= 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 {
getTaskListShelfTone,
isTaskListRowFailureState,
} from '@/lib/taskLifecycleUi';
import { cn } from '@/lib/utils';
import type { ChatStore } from '@/store/chatStore';
import { ChatTaskStatus } from '@/types/constants';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
const TASK_LIST_ROW_BG = {
information: 'bg-ds-bg-splitting-subtle-default hover:brightness-[0.98]',
success: 'bg-ds-bg-completed-subtle-default hover:brightness-[0.98]',
caution: 'bg-ds-bg-error-subtle-default hover:brightness-[0.98]',
idle: 'bg-transparent hover:bg-ds-bg-neutral-default-hover',
} as const;
/** Brief success highlight after a run completes; then idle (transparent). */
const TASK_LIST_SUCCESS_HIGHLIGHT_MS = 1800;
/** Horizontal drift speed for task query hover (~6px/s, capped) — readable marquee, not a snap. */
const TASK_QUERY_SCROLL_PX_PER_SEC = 16;
const TASK_QUERY_SCROLL_MIN_MS = 10_000;
const TASK_QUERY_SCROLL_MAX_MS = 90_000;
function taskQueryScrollDurationMs(scrollPx: number): number {
if (scrollPx <= 0) return 300;
const proportional = (scrollPx / TASK_QUERY_SCROLL_PX_PER_SEC) * 1000;
return Math.min(
TASK_QUERY_SCROLL_MAX_MS,
Math.max(TASK_QUERY_SCROLL_MIN_MS, Math.round(proportional))
);
}
function taskUserQueryLabel(task: ChatStore['tasks'][string]): string {
const firstUser = task.messages.find((m) => m.role === 'user');
const text = firstUser?.content?.trim() ?? '';
return text || '…';
}
function TaskQueryScrollLabel({
queryLabel,
rowHovered,
}: {
queryLabel: string;
rowHovered: boolean;
}) {
const outerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLSpanElement>(null);
const [scrollPx, setScrollPx] = useState(0);
useLayoutEffect(() => {
const outer = outerRef.current;
const inner = innerRef.current;
if (!outer || !inner) return;
const measure = () => {
setScrollPx(Math.max(0, inner.scrollWidth - outer.clientWidth));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(outer);
return () => ro.disconnect();
}, [queryLabel]);
const slide = rowHovered && scrollPx > 0;
const slideMs = taskQueryScrollDurationMs(scrollPx);
return (
<div
ref={outerRef}
className={cn(
'min-w-0 text-ds-text-neutral-muted-default w-full overflow-hidden'
)}
>
<span
ref={innerRef}
title={queryLabel}
className={cn(
'text-body-sm font-normal inline-block whitespace-nowrap first-letter:uppercase',
'transition-[transform]',
slide ? 'ease-linear' : 'ease-out duration-300'
)}
style={{
transform: slide ? `translateX(-${scrollPx}px)` : 'translateX(0)',
transitionDuration: slide ? `${slideMs}ms` : undefined,
}}
>
{queryLabel}
</span>
</div>
);
}
function ChatTimelineRow({
task,
firstUserMessageId,
active,
setScrollToQueryId,
}: {
task: ChatStore['tasks'][string];
firstUserMessageId: string | null;
active: boolean;
setScrollToQueryId: (id: string) => void;
}) {
const [rowHovered, setRowHovered] = useState(false);
const [successHighlight, setSuccessHighlight] = useState(false);
const mountedRef = useRef(false);
const prevStatusRef = useRef(task.status);
const queryLabel = taskUserQueryLabel(task);
const shelfTone = getTaskListShelfTone(task);
const failed = isTaskListRowFailureState(task);
const activeWork = shelfTone === 'splitting' || shelfTone === 'running';
const finishedSuccess =
!failed && task.status === ChatTaskStatus.FINISHED && task.type !== '';
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true;
prevStatusRef.current = task.status;
return;
}
const prev = prevStatusRef.current;
prevStatusRef.current = task.status;
if (
finishedSuccess &&
prev !== ChatTaskStatus.FINISHED &&
task.status === ChatTaskStatus.FINISHED
) {
setSuccessHighlight(true);
const id = window.setTimeout(
() => setSuccessHighlight(false),
TASK_LIST_SUCCESS_HIGHLIGHT_MS
);
return () => clearTimeout(id);
}
}, [task.status, finishedSuccess]);
const rowBg = failed
? TASK_LIST_ROW_BG.caution
: successHighlight && finishedSuccess
? TASK_LIST_ROW_BG.success
: activeWork
? TASK_LIST_ROW_BG.information
: TASK_LIST_ROW_BG.idle;
return (
<button
type="button"
onClick={() => {
if (firstUserMessageId) {
setScrollToQueryId(firstUserMessageId);
}
}}
onMouseEnter={() => setRowHovered(true)}
onMouseLeave={() => setRowHovered(false)}
className={cn(
'no-drag h-8 rounded-lg min-w-0 gap-3 px-3 relative flex w-full max-w-full shrink-0 cursor-pointer items-center text-left transition-colors',
rowBg
)}
aria-current={active ? 'true' : undefined}
>
<TaskQueryScrollLabel queryLabel={queryLabel} rowHovered={rowHovered} />
</button>
);
}
/** Initial fold state for the task timeline rail (session chrome `useState` should match). */
export const CHAT_TIMELINE_DEFAULT_COLLAPSED = true;
/** Below this chat column width, the timeline uses a header dropdown instead of the left rail. */
export const CHAT_TIMELINE_BREAKPOINT_PX = 600;
export type ChatTimelineEntry = {
chatId: string;
taskId: string;
task: ChatStore['tasks'][string];
firstUserMessageId: string | null;
};
export interface ChatTimelineProps {
collapsed: boolean;
entries: ChatTimelineEntry[];
activeTaskId: string | null | undefined;
setScrollToQueryId: (id: string) => void;
title: string;
emptyLabel: string;
}
export function ChatTimeline({
collapsed,
entries,
activeTaskId,
setScrollToQueryId,
title,
emptyLabel,
}: ChatTimelineProps) {
return (
<div
className={cn(
'min-h-0 min-w-0 p-1 flex w-full max-w-[200px] flex-col overflow-hidden',
collapsed ? 'max-h-0 pointer-events-none flex-none' : 'min-h-0 flex-1'
)}
style={{ minHeight: 0 }}
>
<div
className={cn(
'gap-2 pl-3 pr-3 py-2 flex w-full shrink-0 items-center',
collapsed && 'hidden'
)}
>
<span className="min-w-0 text-xs font-semibold text-ds-text-neutral-muted-default truncate">
{title}
</span>
</div>
<div className="min-h-0 min-w-0 w-full flex-1 overflow-x-hidden overflow-y-auto">
{entries.length === 0 ? (
<p className="px-3 text-xs text-ds-text-neutral-muted-default w-full">
{emptyLabel}
</p>
) : (
<div className="gap-2 min-w-0 flex w-full flex-col">
{entries.map(({ chatId, taskId, task, firstUserMessageId }) => (
<ChatTimelineRow
key={`${chatId}-${taskId}`}
task={task}
firstUserMessageId={firstUserMessageId}
active={activeTaskId === taskId}
setScrollToQueryId={setScrollToQueryId}
/>
))}
</div>
)}
</div>
</div>
);
}