mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-23 12:44:45 +00:00
update chatput process content
This commit is contained in:
parent
a1a63a60c5
commit
e91cf31ff6
53 changed files with 1835 additions and 434 deletions
|
|
@ -102,7 +102,7 @@
|
|||
"marked": "^17.0.1",
|
||||
"mime": "^4.1.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"motion": "^12.23.24",
|
||||
"motion": "^12.38.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"papaparse": "^5.5.3",
|
||||
"postprocessing": "^6.37.8",
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export default function BottomBox({
|
|||
if (state === 'splitting')
|
||||
backgroundClass = 'bg-ds-bg-splitting-subtle-default';
|
||||
else if (state === 'confirm')
|
||||
backgroundClass = 'bg-ds-bg-pending-subtle-default';
|
||||
backgroundClass = 'bg-ds-bg-running-subtle-default';
|
||||
|
||||
return (
|
||||
<div className="backdrop-blur-xl rounded-t-2xl bg-ds-bg-neutral-subtle-default relative z-50 flex w-full flex-col">
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ export function UserMessageCard({
|
|||
|
||||
return (
|
||||
<div key={id} className={cn('group/msg relative w-full', className)}>
|
||||
<div className="rounded-xl p-sm bg-ds-bg-neutral-default-default w-full overflow-visible">
|
||||
<div className="rounded-xl py-2 px-4 bg-ds-bg-neutral-default-default w-full overflow-visible">
|
||||
{attaches && attaches.length > 0 && (
|
||||
<div className="mb-2 gap-1 relative box-border flex w-full flex-wrap items-start">
|
||||
{(() => {
|
||||
|
|
|
|||
144
src/components/ChatBox/SplittingProgressRow.tsx
Normal file
144
src/components/ChatBox/SplittingProgressRow.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// ========= 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';
|
||||
|
||||
/** One shared start time per task so inline + bottom splitting rows stay in sync. */
|
||||
const splittingTimerStartMsByTaskId = new Map<string, number>();
|
||||
|
||||
function getOrCreateSplittingTimerStart(taskId: string): number {
|
||||
let started = splittingTimerStartMsByTaskId.get(taskId);
|
||||
if (started === undefined) {
|
||||
started = Date.now();
|
||||
splittingTimerStartMsByTaskId.set(taskId, started);
|
||||
}
|
||||
return started;
|
||||
}
|
||||
|
||||
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();
|
||||
return chatStore.subscribe(sync);
|
||||
}, [chatStore, taskId]);
|
||||
|
||||
if (!taskId) return 0;
|
||||
const startMs = getOrCreateSplittingTimerStart(taskId);
|
||||
return Math.max(0, now - startMs);
|
||||
}
|
||||
|
||||
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 m-2 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-[color:var(--ds-icon-information-default-default)]"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-bold shrink-0 text-[color:var(--ds-text-information-default-default)]">
|
||||
{t('chat.splitting-tasks')}
|
||||
</span>
|
||||
<span className="text-xs font-medium shrink-0 text-[color:var(--ds-text-neutral-muted-default)] tabular-nums">
|
||||
{formatSplittingElapsed(elapsedMs)}
|
||||
</span>
|
||||
<span className="text-xs font-medium shrink-0 text-[color:var(--ds-text-neutral-muted-default)] tabular-nums">
|
||||
{' '}
|
||||
•{' '}
|
||||
</span>
|
||||
<span
|
||||
className="gap-1 text-xs font-medium flex shrink-0 items-center text-[color:var(--ds-text-neutral-muted-default)]"
|
||||
aria-label={`${t('chat.token')}: ${tokens}`}
|
||||
>
|
||||
<AnimatedTokenNumber value={tokens} />
|
||||
Token
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,7 +28,37 @@ import {
|
|||
Plus,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const TASK_CARD_EXPAND_STORAGE_PREFIX = 'eigent:task-card-expanded';
|
||||
|
||||
function getTaskCardExpandStorageKey(
|
||||
chatId: string | undefined,
|
||||
activeTaskId: string | undefined
|
||||
): string | null {
|
||||
if (!activeTaskId) return null;
|
||||
if (chatId)
|
||||
return `${TASK_CARD_EXPAND_STORAGE_PREFIX}:${chatId}:${activeTaskId}`;
|
||||
return `${TASK_CARD_EXPAND_STORAGE_PREFIX}:${activeTaskId}`;
|
||||
}
|
||||
|
||||
function readStoredTaskCardExpanded(key: string | null): boolean {
|
||||
if (!key || typeof window === 'undefined') return false;
|
||||
try {
|
||||
return sessionStorage.getItem(key) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredTaskCardExpanded(key: string | null, expanded: boolean) {
|
||||
if (!key || typeof window === 'undefined') return;
|
||||
try {
|
||||
sessionStorage.setItem(key, expanded ? '1' : '0');
|
||||
} catch {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
}
|
||||
|
||||
interface TaskCardProps {
|
||||
taskInfo: any[];
|
||||
|
|
@ -58,7 +88,6 @@ export function TaskCard({
|
|||
clickable = true,
|
||||
chatId,
|
||||
}: TaskCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [contentHeight, setContentHeight] = useState<number | 'auto'>('auto');
|
||||
const [selectedState, setSelectedState] = useState<TaskStateType>('all');
|
||||
|
|
@ -71,6 +100,17 @@ export function TaskCard({
|
|||
const activeTaskId = chatStore?.activeTaskId as string;
|
||||
const activeTask = chatStore?.tasks?.[activeTaskId];
|
||||
const activeTaskStatus = activeTask?.status;
|
||||
const expandStorageKey = getTaskCardExpandStorageKey(chatId, activeTaskId);
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(() =>
|
||||
readStoredTaskCardExpanded(
|
||||
getTaskCardExpandStorageKey(chatId, activeTaskId)
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsExpanded(readStoredTaskCardExpanded(expandStorageKey));
|
||||
}, [expandStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const tasks = taskRunning || [];
|
||||
|
|
@ -110,10 +150,6 @@ export function TaskCard({
|
|||
}
|
||||
}, [selectedState, taskInfo, taskRunning]);
|
||||
|
||||
const isAllTaskFinished = useMemo(() => {
|
||||
return activeTaskStatus === ChatTaskStatus.FINISHED;
|
||||
}, [activeTaskStatus]);
|
||||
|
||||
// Improved height calculation logic
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) return;
|
||||
|
|
@ -176,7 +212,7 @@ export function TaskCard({
|
|||
|
||||
return (
|
||||
<div>
|
||||
<div className="gap-2 px-sm flex h-auto w-full flex-col transition-all duration-300">
|
||||
<div className="gap-2 px-sm py-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={progressValue} className="h-[2px] w-full" />
|
||||
|
|
@ -289,7 +325,7 @@ export function TaskCard({
|
|||
)}
|
||||
{taskType === 2 && (
|
||||
<div className="gap-2 animate-in fade-in-0 slide-in-from-right-2 flex items-center duration-300">
|
||||
{(isExpanded || isAllTaskFinished) && (
|
||||
{isExpanded && (
|
||||
<div className="text-xs font-medium leading-17 text-ds-text-neutral-subtle-default">
|
||||
{taskRunning?.filter(
|
||||
(task) =>
|
||||
|
|
@ -302,7 +338,13 @@ export function TaskCard({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onClick={() => {
|
||||
setIsExpanded((prev) => {
|
||||
const next = !prev;
|
||||
writeStoredTaskCardExpanded(expandStorageKey, next);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
|
|
@ -399,7 +441,11 @@ export function TaskCard({
|
|||
? 'bg-[var(--ds-bg-status-running-subtle-default)]'
|
||||
: task.status === TaskStatus.BLOCKED
|
||||
? 'bg-[var(--ds-bg-status-blocked-subtle-default)]'
|
||||
: 'bg-[var(--ds-bg-status-running-subtle-default)]'
|
||||
: task.status === TaskStatus.SKIPPED ||
|
||||
task.status === TaskStatus.WAITING ||
|
||||
task.status === TaskStatus.EMPTY
|
||||
? 'bg-[var(--ds-bg-status-pending-subtle-default)]'
|
||||
: 'bg-[var(--ds-bg-status-running-subtle-default)]'
|
||||
} cursor-pointer border border-solid border-transparent ${
|
||||
task.status === TaskStatus.COMPLETED
|
||||
? 'hover:border-[color:var(--ds-border-status-completed-default-focus)]'
|
||||
|
|
@ -409,7 +455,11 @@ export function TaskCard({
|
|||
? 'hover:border-[color:var(--ds-border-status-running-default-focus)]'
|
||||
: task.status === TaskStatus.BLOCKED
|
||||
? 'hover:border-[color:var(--ds-border-status-blocked-default-focus)]'
|
||||
: 'border-transparent'
|
||||
: task.status === TaskStatus.SKIPPED ||
|
||||
task.status === TaskStatus.WAITING ||
|
||||
task.status === TaskStatus.EMPTY
|
||||
? 'hover:border-[color:var(--ds-border-status-pending-default-hover)]'
|
||||
: 'border-transparent'
|
||||
} `}
|
||||
>
|
||||
<div className="pt-0.5">
|
||||
|
|
@ -425,7 +475,7 @@ export function TaskCard({
|
|||
{task.status === TaskStatus.SKIPPED && (
|
||||
<LoaderCircle
|
||||
size={16}
|
||||
className={`text-[color:var(--ds-icon-neutral-muted-default)]`}
|
||||
className="text-[color:var(--ds-icon-status-pending-default-default)]"
|
||||
/>
|
||||
)}
|
||||
{task.status === TaskStatus.COMPLETED && (
|
||||
|
|
@ -446,10 +496,11 @@ export function TaskCard({
|
|||
className="text-[color:var(--ds-icon-warning-default-default)]"
|
||||
/>
|
||||
)}
|
||||
{task.status === TaskStatus.EMPTY && (
|
||||
{(task.status === TaskStatus.EMPTY ||
|
||||
task.status === TaskStatus.WAITING) && (
|
||||
<Circle
|
||||
size={16}
|
||||
className="text-[color:var(--ds-icon-neutral-muted-default)]"
|
||||
className="text-[color:var(--ds-icon-status-pending-default-default)]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -460,7 +511,11 @@ export function TaskCard({
|
|||
? 'text-[color:var(--ds-text-caution-default-default)]'
|
||||
: task.status === TaskStatus.BLOCKED
|
||||
? 'text-[color:var(--ds-text-warning-default-default)]'
|
||||
: 'text-[color:var(--ds-text-neutral-default-default)]'
|
||||
: task.status === TaskStatus.SKIPPED ||
|
||||
task.status === TaskStatus.WAITING ||
|
||||
task.status === TaskStatus.EMPTY
|
||||
? 'text-[color:var(--ds-text-status-pending-default-default)]'
|
||||
: 'text-[color:var(--ds-text-neutral-default-default)]'
|
||||
} text-sm font-medium leading-13`}
|
||||
>
|
||||
{task.content}
|
||||
|
|
|
|||
391
src/components/ChatBox/TaskWorkLogAccordion.tsx
Normal file
391
src/components/ChatBox/TaskWorkLogAccordion.tsx
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
// ========= 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 { MarkDown } from '@/components/WorkFlow/MarkDown';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { VanillaChatStore } from '@/store/chatStore';
|
||||
import { AgentStep, ChatTaskStatus } from '@/types/constants';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatSplittingElapsed } from './TokenUtils';
|
||||
|
||||
function normalizeToolkitMessage(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value == null) return '';
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Matches `getFormattedTaskTime` / task timer fields on the chat task. */
|
||||
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 mergeAgentLogs(taskAssigning: Agent[] | undefined): AgentMessage[] {
|
||||
if (!taskAssigning?.length) return [];
|
||||
return taskAssigning.flatMap((a) => a.log ?? []);
|
||||
}
|
||||
|
||||
function titleCaseMethod(method: string): string {
|
||||
if (!method) return '';
|
||||
return method.charAt(0).toUpperCase() + method.slice(1);
|
||||
}
|
||||
|
||||
function truncateText(text: string, max: number): string {
|
||||
const t = text.trim();
|
||||
if (t.length <= max) return t;
|
||||
return `${t.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
function formatToolSummary(
|
||||
toolkitName: string,
|
||||
method: string,
|
||||
preview: string
|
||||
): string {
|
||||
const p = preview.trim();
|
||||
const head = `${toolkitName} · ${titleCaseMethod(method)}`;
|
||||
return p ? `${head} — ${truncateText(p, 100)}` : head;
|
||||
}
|
||||
|
||||
/** Collapsed row: only the toolkit · method prefix; preview after — or - stays in expanded detail. */
|
||||
function summaryRowLabel(fullSummary: string): string {
|
||||
const em = fullSummary.split(' — ');
|
||||
if (em.length > 1) return em[0]!.trim();
|
||||
const spacedHyphen = fullSummary.split(' - ');
|
||||
if (spacedHyphen.length > 1) return spacedHyphen[0]!.trim();
|
||||
return fullSummary.trim();
|
||||
}
|
||||
|
||||
type ToolSegment = {
|
||||
type: 'tool';
|
||||
toolkitName: string;
|
||||
method: string;
|
||||
summary: string;
|
||||
detail: string;
|
||||
status: 'running' | 'done';
|
||||
};
|
||||
|
||||
type AgentSegment = { type: 'agent'; text: string };
|
||||
|
||||
type LogSegment = AgentSegment | ToolSegment;
|
||||
|
||||
function buildLogSegments(merged: AgentMessage[]): LogSegment[] {
|
||||
const segments: LogSegment[] = [];
|
||||
|
||||
for (const entry of merged) {
|
||||
if (entry.step === AgentStep.ACTIVATE_AGENT) {
|
||||
const text = normalizeToolkitMessage(entry.data?.message).trim();
|
||||
if (text) segments.push({ type: 'agent', text });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.step === AgentStep.ACTIVATE_TOOLKIT) {
|
||||
const name = (entry.data?.toolkit_name ?? '').trim() || 'Tool';
|
||||
const method = (entry.data?.method_name ?? '').trim();
|
||||
const rawMsg = normalizeToolkitMessage(entry.data?.message).trim();
|
||||
|
||||
if (name.toLowerCase() === 'notice') {
|
||||
if (rawMsg) segments.push({ type: 'agent', text: rawMsg });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!method && !rawMsg) continue;
|
||||
|
||||
segments.push({
|
||||
type: 'tool',
|
||||
toolkitName: name,
|
||||
method,
|
||||
summary: formatToolSummary(name, method, rawMsg),
|
||||
detail: rawMsg,
|
||||
status: 'running',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.step === AgentStep.DEACTIVATE_TOOLKIT) {
|
||||
const name = (entry.data?.toolkit_name ?? '').trim();
|
||||
const method = (entry.data?.method_name ?? '').trim();
|
||||
const msg = normalizeToolkitMessage(entry.data?.message).trim();
|
||||
|
||||
for (let i = segments.length - 1; i >= 0; i--) {
|
||||
const s = segments[i];
|
||||
if (s.type !== 'tool') continue;
|
||||
if (s.status !== 'running') continue;
|
||||
if (s.toolkitName !== name || s.method !== method) continue;
|
||||
|
||||
s.status = 'done';
|
||||
s.detail = [s.detail, msg].filter(Boolean).join('\n\n').trim();
|
||||
s.summary = formatToolSummary(name, method, s.detail);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap digest of the task slice that affects this accordion, so any chat store
|
||||
* mutation re-renders without relying on `updateCount` (rarely bumped).
|
||||
*/
|
||||
function useTaskWorkStoreSnapshot(
|
||||
chatStore: VanillaChatStore,
|
||||
taskId: string | null
|
||||
) {
|
||||
return useSyncExternalStore(
|
||||
(cb) => chatStore.subscribe(cb),
|
||||
() => {
|
||||
if (!taskId) return '';
|
||||
const t = chatStore.getState().tasks[taskId];
|
||||
if (!t) return '';
|
||||
const logDigest = (t.taskAssigning ?? [])
|
||||
.map((a) => {
|
||||
const log = a.log ?? [];
|
||||
const last = log[log.length - 1];
|
||||
const msg = last?.data?.message;
|
||||
const msgLen =
|
||||
typeof msg === 'string'
|
||||
? msg.length
|
||||
: msg != null
|
||||
? JSON.stringify(msg).length
|
||||
: 0;
|
||||
return `${log.length}:${last?.step ?? ''}:${msgLen}:${last?.data?.toolkit_name ?? ''}:${last?.data?.method_name ?? ''}`;
|
||||
})
|
||||
.join('>');
|
||||
return `${t.status}|${t.taskTime}|${t.elapsed}|${logDigest}`;
|
||||
},
|
||||
() => ''
|
||||
);
|
||||
}
|
||||
|
||||
function useTaskWorkLogData(
|
||||
chatStore: VanillaChatStore,
|
||||
taskId: string | null,
|
||||
_snapshot: string
|
||||
) {
|
||||
return useMemo(() => {
|
||||
if (!taskId) {
|
||||
return { task: undefined, segments: [] as LogSegment[] };
|
||||
}
|
||||
const t = chatStore.getState().tasks[taskId];
|
||||
const merged = mergeAgentLogs(t?.taskAssigning);
|
||||
const segments = buildLogSegments(merged);
|
||||
return { task: t, segments };
|
||||
}, [chatStore, taskId, _snapshot]);
|
||||
}
|
||||
|
||||
function useWorkLogElapsedMs(
|
||||
chatStore: VanillaChatStore,
|
||||
taskId: string | null,
|
||||
snapshot: string
|
||||
): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const t = taskId ? chatStore.getState().tasks[taskId] : null;
|
||||
if (t?.status !== ChatTaskStatus.RUNNING) return;
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [chatStore, taskId, snapshot]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!taskId) return 0;
|
||||
const t = chatStore.getState().tasks[taskId];
|
||||
if (!t) return 0;
|
||||
return getTaskElapsedMs(t);
|
||||
}, [chatStore, taskId, snapshot, now]);
|
||||
}
|
||||
|
||||
function ToolDetailRow({
|
||||
summary,
|
||||
detail,
|
||||
}: {
|
||||
summary: string;
|
||||
detail: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex w-full flex-col items-start">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="min-w-0 gap-1 py-2 px-2 inline-flex max-w-full items-center self-start text-left transition-opacity hover:opacity-80"
|
||||
>
|
||||
<span className="text-body-sm font-medium min-w-0 text-ds-text-neutral-muted-default shrink overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{summaryRowLabel(summary)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'text-ds-icon-neutral-muted-default shrink-0 transition-transform duration-200',
|
||||
open && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 ease-in-out mx-2 w-full overflow-hidden transition-all duration-200',
|
||||
open ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
{detail ? (
|
||||
<div className="pb-2 pl-0 pr-0 pt-0">
|
||||
<MarkDown
|
||||
content={detail}
|
||||
enableTypewriter={false}
|
||||
pTextSize="text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TaskWorkLogAccordionProps {
|
||||
chatStore: VanillaChatStore;
|
||||
taskId: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TaskWorkLogAccordion({
|
||||
chatStore,
|
||||
taskId,
|
||||
className,
|
||||
}: TaskWorkLogAccordionProps) {
|
||||
const { t } = useTranslation();
|
||||
const snapshot = useTaskWorkStoreSnapshot(chatStore, taskId);
|
||||
const { task, segments } = useTaskWorkLogData(chatStore, taskId, snapshot);
|
||||
const status = task?.status;
|
||||
const elapsedMs = useWorkLogElapsedMs(chatStore, taskId, snapshot);
|
||||
|
||||
const [outerOpen, setOuterOpen] = useState(
|
||||
() => status === ChatTaskStatus.RUNNING
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === ChatTaskStatus.FINISHED) {
|
||||
setOuterOpen(false);
|
||||
} else if (status === ChatTaskStatus.RUNNING) {
|
||||
setOuterOpen(true);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
if (!taskId || !task) return null;
|
||||
|
||||
const allowed =
|
||||
status === ChatTaskStatus.RUNNING ||
|
||||
status === ChatTaskStatus.FINISHED ||
|
||||
status === ChatTaskStatus.PAUSE;
|
||||
|
||||
if (!allowed) return null;
|
||||
|
||||
if (status !== ChatTaskStatus.RUNNING && segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeLabel = formatSplittingElapsed(elapsedMs);
|
||||
const headerRunning = t('chat.working-on-tasks-for', { time: timeLabel });
|
||||
const headerDone = t('chat.worked-for', { time: timeLabel });
|
||||
const headerText =
|
||||
status === ChatTaskStatus.RUNNING || status === ChatTaskStatus.PAUSE
|
||||
? headerRunning
|
||||
: headerDone;
|
||||
|
||||
const useTypewriterForAgent =
|
||||
outerOpen &&
|
||||
(status === ChatTaskStatus.FINISHED || status === ChatTaskStatus.PAUSE);
|
||||
|
||||
return (
|
||||
<div className={cn('min-w-0 my-2 flex w-full flex-col', className)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={outerOpen}
|
||||
onClick={() => setOuterOpen((v) => !v)}
|
||||
className="gap-1 py-2 px-2 min-w-0 flex w-full items-center justify-start text-left"
|
||||
>
|
||||
<span className="text-body-sm font-medium text-ds-text-neutral-muted-default tabular-nums">
|
||||
{headerText}
|
||||
</span>
|
||||
{outerOpen ? (
|
||||
<ChevronDown
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
aria-hidden
|
||||
className="text-ds-icon-neutral-muted-default shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
aria-hidden
|
||||
className="text-ds-icon-neutral-muted-default shrink-0"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'ease-in-out overflow-hidden transition-all duration-200',
|
||||
outerOpen ? 'max-h-[8000px] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className="gap-3 pb-1 min-w-0 flex flex-col">
|
||||
{segments.map((seg, index) => {
|
||||
if (seg.type === 'agent') {
|
||||
return (
|
||||
<div
|
||||
key={`agent-${index}-${seg.text.slice(0, 24)}`}
|
||||
className="min-w-0"
|
||||
>
|
||||
{useTypewriterForAgent ? (
|
||||
<MarkDown
|
||||
key={`tw-${index}-${outerOpen}`}
|
||||
content={seg.text}
|
||||
enableTypewriter
|
||||
speed={12}
|
||||
pTextSize="text-sm"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-body-sm font-medium m-0 leading-snug text-ds-text-neutral-default-default break-words whitespace-pre-wrap">
|
||||
{seg.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolDetailRow
|
||||
key={`tool-${index}-${seg.toolkitName}-${seg.method}`}
|
||||
summary={seg.summary}
|
||||
detail={seg.detail}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,6 +28,19 @@ const TOKEN_UNITS = [
|
|||
{ threshold: 1_000, suffix: 'K' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Elapsed time during splitting / planning: seconds, then minutes + seconds.
|
||||
* Examples: "0s", "45s", "1m 05s", "12m 00s"
|
||||
*/
|
||||
export function formatSplittingElapsed(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '0s';
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}m ${s.toString().padStart(2, '0')}s`;
|
||||
}
|
||||
|
||||
export function formatTokenCount(n: number): string {
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
|
||||
|
|
|
|||
|
|
@ -27,9 +27,10 @@ import { AgentMessageCard } from './MessageItem/AgentMessageCard';
|
|||
import { NoticeCard } from './MessageItem/NoticeCard';
|
||||
import { TaskCompletionCard } from './MessageItem/TaskCompletionCard';
|
||||
import { UserMessageCard } from './MessageItem/UserMessageCard';
|
||||
import { SplittingProgressRow } from './SplittingProgressRow';
|
||||
import { StreamingTaskList } from './TaskBox/StreamingTaskList';
|
||||
import { TaskCard } from './TaskBox/TaskCard';
|
||||
import { TypeCardSkeleton } from './TaskBox/TypeCardSkeleton';
|
||||
import { TaskWorkLogAccordion } from './TaskWorkLogAccordion';
|
||||
|
||||
/** Collapsible card that shows a single agent's result (workforce / non–single-agent turns). */
|
||||
const AgentResultCard: React.FC<{
|
||||
|
|
@ -368,6 +369,17 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
</motion.div>
|
||||
)}
|
||||
|
||||
{taskCardVisible && activeTaskId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.05 }}
|
||||
className="px-sm"
|
||||
>
|
||||
<TaskWorkLogAccordion chatStore={chatStore} taskId={activeTaskId} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Other Messages */}
|
||||
{queryGroup.otherMessages.map((message) => {
|
||||
if (message.content.length > 0) {
|
||||
|
|
@ -378,7 +390,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="gap-4 px-sm flex flex-col"
|
||||
className="gap-4 flex flex-col"
|
||||
>
|
||||
<AgentMessageCard
|
||||
typewriter={
|
||||
|
|
@ -391,7 +403,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
onMarkdownRenderComplete={onTaskCompletionMarkdownReady}
|
||||
deferredFooter={
|
||||
message.fileList?.length ? (
|
||||
<div className="gap-2 flex flex-wrap">
|
||||
<div className="gap-2 my-2 flex flex-wrap">
|
||||
{message.fileList.map(
|
||||
(file: any, fileIndex: number) => (
|
||||
<motion.div
|
||||
|
|
@ -409,13 +421,13 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
'documentWorkSpace'
|
||||
);
|
||||
}}
|
||||
className="gap-2 rounded-sm px-2 py-1 flex w-[140px] cursor-pointer items-center bg-[var(--ds-bg-neutral-default-default)] transition-colors hover:bg-[var(--ds-bg-neutral-default-hover)]"
|
||||
className="gap-2 rounded-lg py-2 px-3 bg-ds-bg-neutral-default-default hover:bg-ds-bg-neutral-default-hover flex w-[140px] cursor-pointer items-center transition-colors"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-body text-sm font-bold max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap text-[color:var(--ds-text-neutral-default-default)]">
|
||||
<div className="text-body-sm font-bold text-ds-text-neutral-default-default max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{file.name.split('.')[0]}
|
||||
</div>
|
||||
<div className="text-xs font-medium leading-29 text-[color:var(--ds-text-neutral-default-default)]">
|
||||
<div className="text-label-xs font-medium text-ds-text-neutral-muted-default">
|
||||
{file.type}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -435,7 +447,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="gap-4 px-sm flex flex-col"
|
||||
className="gap-4 flex flex-col"
|
||||
>
|
||||
<AgentMessageCard
|
||||
key={message.id}
|
||||
|
|
@ -470,7 +482,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="gap-4 px-sm flex flex-col"
|
||||
className="gap-4 flex flex-col"
|
||||
>
|
||||
<AgentMessageCard
|
||||
key={message.id}
|
||||
|
|
@ -493,7 +505,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="gap-4 px-sm flex flex-col"
|
||||
className="gap-4 flex flex-col"
|
||||
>
|
||||
{message.fileList && (
|
||||
<div className="gap-2 flex flex-wrap">
|
||||
|
|
@ -575,14 +587,14 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
<StreamingTaskList streamingText={streamingDecomposeText} />
|
||||
)}
|
||||
|
||||
{/* Skeleton for loading state */}
|
||||
{isSkeletonPhase && (
|
||||
{isSkeletonPhase && activeTaskId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="px-sm"
|
||||
>
|
||||
<TypeCardSkeleton isTakeControl={task?.isTakeControl || false} />
|
||||
<SplittingProgressRow chatStore={chatStore} taskId={activeTaskId} />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,14 @@ import { useTranslation } from 'react-i18next';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import ProjectDialog from './ProjectDialog';
|
||||
|
||||
const compactCountFormatter = new Intl.NumberFormat('en', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
const formatCompactCount = (value?: number) =>
|
||||
compactCountFormatter.format(value || 0).replace('.0', '');
|
||||
|
||||
interface ProjectGroupProps {
|
||||
project: ProjectGroupType;
|
||||
onTaskSelect: (
|
||||
|
|
@ -345,12 +353,11 @@ export default function ProjectGroup({
|
|||
tone="information"
|
||||
emphasis="default"
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Hash />
|
||||
<span>
|
||||
{project.total_tokens
|
||||
? project.total_tokens.toLocaleString()
|
||||
: '0'}
|
||||
<Hash className="h-3 w-3" />
|
||||
<span className="text-label-xs">
|
||||
{formatCompactCount(project.total_tokens)}
|
||||
</span>
|
||||
</Tag>
|
||||
</TooltipSimple>
|
||||
|
|
@ -359,12 +366,15 @@ export default function ProjectGroup({
|
|||
<TooltipSimple content={t('layout.tasks')}>
|
||||
<Tag
|
||||
variant="primary"
|
||||
tone="neutral"
|
||||
tone="default"
|
||||
emphasis="default"
|
||||
size="xs"
|
||||
className="min-w-10"
|
||||
className="gap-1.5 min-w-10"
|
||||
>
|
||||
<ListChecks />
|
||||
<span>{project.task_count}</span>
|
||||
<ListChecks className="h-3 w-3" />
|
||||
<span className="text-label-xs">
|
||||
{formatCompactCount(project.task_count)}
|
||||
</span>
|
||||
</Tag>
|
||||
</TooltipSimple>
|
||||
|
||||
|
|
@ -372,11 +382,14 @@ export default function ProjectGroup({
|
|||
<Tag
|
||||
variant="primary"
|
||||
tone="warning"
|
||||
emphasis="default"
|
||||
size="xs"
|
||||
className="min-w-10"
|
||||
className="gap-1.5 min-w-10"
|
||||
>
|
||||
<Zap />
|
||||
<span>{project.total_triggers || 0}</span>
|
||||
<Zap className="h-3 w-3" />
|
||||
<span className="text-label-xs">
|
||||
{formatCompactCount(project.total_triggers)}
|
||||
</span>
|
||||
</Tag>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
|
|
@ -420,30 +433,32 @@ export default function ProjectGroup({
|
|||
</div>
|
||||
|
||||
{/* Middle: Project, Trigger, Agent tags - Aligned to right */}
|
||||
<div className="gap-4 flex w-fit flex-1 items-center justify-end">
|
||||
<div className="gap-2 flex w-fit flex-1 items-center justify-end">
|
||||
<Tag
|
||||
variant="primary"
|
||||
tone="information"
|
||||
emphasis="default"
|
||||
size="sm"
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Hash />
|
||||
<span>
|
||||
{project.total_tokens
|
||||
? project.total_tokens.toLocaleString()
|
||||
: '0'}
|
||||
<Hash className="h-3 w-3" />
|
||||
<span className="text-label-xs">
|
||||
{formatCompactCount(project.total_tokens)}
|
||||
</span>
|
||||
</Tag>
|
||||
|
||||
<TooltipSimple content={t('layout.tasks')}>
|
||||
<Tag
|
||||
variant="primary"
|
||||
tone="neutral"
|
||||
size="sm"
|
||||
className="min-w-10"
|
||||
tone="default"
|
||||
emphasis="default"
|
||||
size="xs"
|
||||
className="gap-1.5 min-w-10"
|
||||
>
|
||||
<ListChecks />
|
||||
<span>{project.task_count}</span>
|
||||
<ListChecks className="h-3 w-3" />
|
||||
<span className="text-label-xs">
|
||||
{formatCompactCount(project.task_count)}
|
||||
</span>
|
||||
</Tag>
|
||||
</TooltipSimple>
|
||||
|
||||
|
|
@ -451,11 +466,14 @@ export default function ProjectGroup({
|
|||
<Tag
|
||||
variant="primary"
|
||||
tone="warning"
|
||||
size="sm"
|
||||
className="min-w-10"
|
||||
emphasis="default"
|
||||
size="xs"
|
||||
className="gap-1.5 min-w-10"
|
||||
>
|
||||
<Zap />
|
||||
<span>{project.total_triggers || 0}</span>
|
||||
<Zap className="h-3 w-3" />
|
||||
<span className="text-label-xs">
|
||||
{formatCompactCount(project.total_triggers)}
|
||||
</span>
|
||||
</Tag>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { proxyFetchDelete } from '@/api/http';
|
||||
import { Sparkle } from '@/components/ui/animate-ui/icons/sparkle';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
||||
import { loadProjectFromHistory } from '@/lib';
|
||||
|
|
@ -26,12 +25,14 @@ import { HistoryTask, ProjectGroup } from '@/types/history';
|
|||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
Ellipsis,
|
||||
FolderCheck,
|
||||
FolderClock,
|
||||
Hash,
|
||||
Pin,
|
||||
ListChecks,
|
||||
Plus,
|
||||
Share,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -47,6 +48,14 @@ import { Tag } from '../ui/tag';
|
|||
import { TooltipSimple } from '../ui/tooltip';
|
||||
import SearchInput from './SearchInput';
|
||||
|
||||
const compactCountFormatter = new Intl.NumberFormat('en', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
const formatCompactCount = (value?: number) =>
|
||||
compactCountFormatter.format(value || 0).replace('.0', '');
|
||||
|
||||
export default function HistorySidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, close } = useSidebarStore();
|
||||
|
|
@ -67,7 +76,7 @@ export default function HistorySidebar() {
|
|||
useEffect(() => {
|
||||
if (!chatStore) return;
|
||||
fetchGroupedHistoryTasks(setHistoryTasks);
|
||||
}, [chatStore?.updateCount]);
|
||||
}, [chatStore, chatStore?.updateCount]);
|
||||
|
||||
// Group ongoing tasks by project
|
||||
const ongoingProjects = useMemo(() => {
|
||||
|
|
@ -112,6 +121,9 @@ export default function HistorySidebar() {
|
|||
tasks: [],
|
||||
task_count: taskCount,
|
||||
total_tokens: totalTokens,
|
||||
total_triggers:
|
||||
historyTasks.find((item) => item.project_id === project.id)
|
||||
?.total_triggers || 0,
|
||||
last_prompt: lastPrompt,
|
||||
isOngoing: true,
|
||||
});
|
||||
|
|
@ -119,7 +131,7 @@ export default function HistorySidebar() {
|
|||
});
|
||||
|
||||
return Array.from(projectMap.values());
|
||||
}, [projectStore, chatStore]);
|
||||
}, [projectStore, chatStore, historyTasks]);
|
||||
|
||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.value) {
|
||||
|
|
@ -420,10 +432,7 @@ export default function HistorySidebar() {
|
|||
}}
|
||||
className="gap-sm rounded-xl px-4 py-3 shadow-history-item border-ds-border-neutral-subtle-default bg-ds-bg-neutral-default-default hover:bg-ds-bg-neutral-default-hover relative flex w-full max-w-full cursor-pointer items-center justify-between border border-solid transition-all duration-300"
|
||||
>
|
||||
<Sparkles
|
||||
size={20}
|
||||
className="text-ds-icon-status-splitting-default-default flex-shrink-0"
|
||||
/>
|
||||
<FolderClock className="h-5 w-5 text-ds-icon-status-running-default-default flex-shrink-0" />
|
||||
|
||||
<div className="min-w-0 gap-1 flex flex-1 flex-col">
|
||||
<TooltipSimple
|
||||
|
|
@ -443,19 +452,46 @@ export default function HistorySidebar() {
|
|||
|
||||
<div className="gap-2 flex flex-shrink-0 items-center">
|
||||
<TooltipSimple content={t('chat.token')}>
|
||||
<Tag variant="primary" tone="information" size="sm">
|
||||
<Hash className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">
|
||||
{(project.total_tokens || 0).toLocaleString()}
|
||||
<Tag
|
||||
variant="primary"
|
||||
tone="information"
|
||||
emphasis="default"
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Hash className="h-3 w-3" />
|
||||
<span className="text-label-xs">
|
||||
{formatCompactCount(project.total_tokens)}
|
||||
</span>
|
||||
</Tag>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple content="Tasks">
|
||||
<Tag variant="primary" tone="neutral" size="sm">
|
||||
<Pin className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">
|
||||
{project.task_count}
|
||||
<TooltipSimple content={t('layout.tasks')}>
|
||||
<Tag
|
||||
variant="primary"
|
||||
tone="default"
|
||||
emphasis="default"
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<ListChecks className="h-3 w-3" />
|
||||
<span className="text-label-xs">
|
||||
{formatCompactCount(project.task_count)}
|
||||
</span>
|
||||
</Tag>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple content="Triggers">
|
||||
<Tag
|
||||
variant="primary"
|
||||
tone="warning"
|
||||
emphasis="default"
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
<span className="text-label-xs">
|
||||
{formatCompactCount(project.total_triggers)}
|
||||
</span>
|
||||
</Tag>
|
||||
</TooltipSimple>
|
||||
|
|
@ -538,10 +574,7 @@ export default function HistorySidebar() {
|
|||
key={project.project_id}
|
||||
className="gap-sm rounded-xl px-4 py-3 shadow-history-item border-ds-border-neutral-subtle-default bg-ds-bg-neutral-default-default hover:bg-ds-bg-neutral-default-hover relative flex w-full max-w-full cursor-pointer items-center justify-between border border-solid transition-all duration-300"
|
||||
>
|
||||
<Sparkle
|
||||
size={20}
|
||||
className="text-ds-icon-neutral-muted-default flex-shrink-0"
|
||||
/>
|
||||
<FolderCheck className="h-5 w-5 text-ds-icon-neutral-subtle-default flex-shrink-0" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<TooltipSimple
|
||||
|
|
@ -565,19 +598,46 @@ export default function HistorySidebar() {
|
|||
|
||||
<div className="gap-2 flex flex-shrink-0 items-center">
|
||||
<TooltipSimple content={t('chat.token')}>
|
||||
<Tag variant="primary" tone="information" size="sm">
|
||||
<Hash className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">
|
||||
{(project.total_tokens || 0).toLocaleString()}
|
||||
<Tag
|
||||
variant="primary"
|
||||
tone="information"
|
||||
emphasis="default"
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Hash className="h-3 w-3" />
|
||||
<span className="text-label-xs">
|
||||
{formatCompactCount(project.total_tokens)}
|
||||
</span>
|
||||
</Tag>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple content="Tasks">
|
||||
<Tag variant="primary" tone="neutral" size="sm">
|
||||
<Pin className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">
|
||||
{project.task_count}
|
||||
<TooltipSimple content={t('layout.tasks')}>
|
||||
<Tag
|
||||
variant="primary"
|
||||
tone="default"
|
||||
emphasis="default"
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<ListChecks className="h-3 w-3" />
|
||||
<span className="text-label-xs">
|
||||
{formatCompactCount(project.task_count)}
|
||||
</span>
|
||||
</Tag>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple content="Triggers">
|
||||
<Tag
|
||||
variant="primary"
|
||||
tone="warning"
|
||||
emphasis="default"
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
<span className="text-label-xs">
|
||||
{formatCompactCount(project.total_triggers)}
|
||||
</span>
|
||||
</Tag>
|
||||
</TooltipSimple>
|
||||
|
|
|
|||
|
|
@ -60,11 +60,15 @@ export function BottomAction({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onEndProjectClick}
|
||||
className={cn(workspaceTabButtonClass(false), folded && 'gap-0')}
|
||||
className={cn(
|
||||
workspaceTabButtonClass(false),
|
||||
'bg-ds-bg-error-subtle-default hover:bg-ds-bg-status-error-subtle-hover active:bg-ds-bg-status-error-subtle-active',
|
||||
folded && 'gap-0'
|
||||
)}
|
||||
aria-label={endProjectAriaLabel}
|
||||
>
|
||||
<Power
|
||||
className="h-4 w-4 text-ds-icon-error-default-default shrink-0"
|
||||
className="h-4 w-4 !text-ds-icon-error-default-default shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<motion.span
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { usePageTabStore } from '@/store/pageTabStore';
|
|||
import { useProjectStore } from '@/store/projectStore';
|
||||
import { useTriggerStore } from '@/store/triggerStore';
|
||||
import { ChatTaskStatus } from '@/types/constants';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Inbox, LayoutGrid, Plus, Zap, ZapOff } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -411,35 +411,36 @@ export default function ProjectPageSidebar({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{!projectSidebarFolded ? (
|
||||
<motion.div
|
||||
key="nav-list"
|
||||
className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden"
|
||||
initial={false}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={PROJECT_SIDEBAR_FOLD_SPRING}
|
||||
>
|
||||
<NavList
|
||||
className="min-h-0 mt-6 flex flex-1 flex-col"
|
||||
sessions={navSessions}
|
||||
activeSessionId={
|
||||
activeWorkspaceTab === 'session'
|
||||
? chatStore.activeTaskId
|
||||
: null
|
||||
}
|
||||
onSessionClick={(id) => {
|
||||
chatStore.setActiveTaskId(id);
|
||||
setActiveWorkspaceTab('session');
|
||||
}}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onShowAll={handleShowAllSessions}
|
||||
showAllActive={activeWorkspaceTab === 'sessions'}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<motion.div
|
||||
className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: projectSidebarFolded ? 0 : 1,
|
||||
y: projectSidebarFolded ? -6 : 0,
|
||||
}}
|
||||
transition={PROJECT_SIDEBAR_FOLD_SPRING}
|
||||
aria-hidden={projectSidebarFolded}
|
||||
style={{
|
||||
pointerEvents: projectSidebarFolded ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
<NavList
|
||||
className="min-h-0 mt-6 flex flex-1 flex-col"
|
||||
sessions={navSessions}
|
||||
activeSessionId={
|
||||
activeWorkspaceTab === 'session'
|
||||
? chatStore.activeTaskId
|
||||
: null
|
||||
}
|
||||
onSessionClick={(id) => {
|
||||
chatStore.setActiveTaskId(id);
|
||||
setActiveWorkspaceTab('session');
|
||||
}}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onShowAll={handleShowAllSessions}
|
||||
showAllActive={activeWorkspaceTab === 'sessions'}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<BottomAction
|
||||
|
|
|
|||
|
|
@ -17,25 +17,41 @@ import { AnimatePresence, motion } from 'framer-motion';
|
|||
import { ChevronDown } from 'lucide-react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
const CONTENT_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
||||
const LAYOUT_TRANSITION = {
|
||||
layout: { duration: 0.28, ease: CONTENT_EASE },
|
||||
} as const;
|
||||
|
||||
export type SidePanelAccordionRenderArgs = { open: boolean };
|
||||
|
||||
export type SidePanelAccordionChildren =
|
||||
| ReactNode
|
||||
| ((state: SidePanelAccordionRenderArgs) => ReactNode);
|
||||
|
||||
export function SidePanelAccordionBox({
|
||||
title,
|
||||
titleSuffix,
|
||||
collapsedPreview,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
title: string;
|
||||
/** Small adornment rendered right after the title (e.g. count pill). */
|
||||
titleSuffix?: ReactNode;
|
||||
/** Compact content rendered below the header when the accordion is collapsed. */
|
||||
collapsedPreview?: ReactNode;
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Static: classic accordion — body hidden when closed.
|
||||
* Render prop: body stays in one region; switch layout by `open` (e.g. summary vs full list).
|
||||
*/
|
||||
children: SidePanelAccordionChildren;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const isRenderProp = typeof children === 'function';
|
||||
const dynamicBody = isRenderProp
|
||||
? (children as (s: SidePanelAccordionRenderArgs) => ReactNode)({ open })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-ds-bg-neutral-default-default border-ds-border-neutral-subtle-disabled ease-in-out min-w-0 z-10 flex shrink-0 flex-col overflow-hidden border border-solid transition-all duration-200">
|
||||
<div className="rounded-xl bg-ds-bg-neutral-default-default border-ds-border-neutral-subtle-disabled min-w-0 z-10 flex shrink-0 flex-col overflow-hidden border border-solid">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
|
|
@ -52,31 +68,42 @@ export function SidePanelAccordionBox({
|
|||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'text-ds-text-neutral-muted-default h-4 w-4 shrink-0 transition-transform duration-200',
|
||||
'text-ds-text-neutral-muted-default h-4 w-4 ease-out shrink-0 transition-transform duration-200',
|
||||
open ? 'rotate-0' : '-rotate-90'
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
{!open && collapsedPreview ? (
|
||||
<div className="px-3 pb-3 w-full">{collapsedPreview}</div>
|
||||
) : null}
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
<motion.div
|
||||
key="content"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-3 pb-3 w-full">{children}</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{isRenderProp ? (
|
||||
<motion.div
|
||||
layout
|
||||
transition={LAYOUT_TRANSITION}
|
||||
className="min-h-0 w-full overflow-hidden"
|
||||
>
|
||||
{dynamicBody != null ? (
|
||||
<div className="px-2 pb-3 w-full">{dynamicBody}</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
) : (
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
<motion.div
|
||||
key="static-content"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{
|
||||
height: { duration: 0.22, ease: CONTENT_EASE },
|
||||
opacity: { duration: 0.16, ease: CONTENT_EASE },
|
||||
}}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-2 pb-3 w-full">{children as ReactNode}</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,8 @@ function iconFor(file: FileInfo): LucideIcon {
|
|||
interface AgentFolderSectionProps {
|
||||
title: string;
|
||||
files: FileInfo[];
|
||||
onOpenFile?: (file: FileInfo) => void;
|
||||
/** Opens the Folder workspace tab and selects this file (parent supplies navigation). */
|
||||
onOpenFile: (file: FileInfo) => void;
|
||||
}
|
||||
|
||||
export function AgentFolderSection({
|
||||
|
|
@ -126,7 +127,7 @@ export function AgentFolderSection({
|
|||
className={cn('text-ds-icon-neutral-default-default')}
|
||||
/>
|
||||
}
|
||||
onClick={onOpenFile ? () => onOpenFile(file) : undefined}
|
||||
onClick={() => onOpenFile(file)}
|
||||
>
|
||||
{file.name || file.path}
|
||||
</SidePanelListRow>
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ function getAgentSubIcon(agentType: string): ReactNode {
|
|||
function AgentLeadingIcon({ agentType }: { agentType: string }) {
|
||||
const subIcon = getAgentSubIcon(agentType);
|
||||
return (
|
||||
<div className="h-6 w-6 text-ds-text-neutral-muted-default relative inline-flex shrink-0 items-center justify-center self-center">
|
||||
<Bot className="h-6 w-6" strokeWidth={2} aria-hidden />
|
||||
<div className="h-6 w-6 text-ds-text-neutral-muted-default bg-ds-bg-neutral-subtle-default relative inline-flex shrink-0 items-center justify-center self-center">
|
||||
<Bot className="h-4 w-4" strokeWidth={2} aria-hidden />
|
||||
{subIcon != null && (
|
||||
<span className="-right-1 -top-1 absolute inline-flex items-center justify-center [&_svg]:shrink-0">
|
||||
{subIcon}
|
||||
|
|
@ -85,6 +85,7 @@ function AgentRow({ agent }: { agent: Agent }) {
|
|||
|
||||
return (
|
||||
<SidePanelListRow
|
||||
className="rounded-lg bg-ds-bg-neutral-subtle-default"
|
||||
leading={<AgentLeadingIcon agentType={agent.type} />}
|
||||
disabled={!active}
|
||||
>
|
||||
|
|
@ -93,6 +94,27 @@ function AgentRow({ agent }: { agent: Agent }) {
|
|||
);
|
||||
}
|
||||
|
||||
function AgentList({ agents }: { agents: Agent[] }) {
|
||||
return (
|
||||
<motion.ul layout className="gap-2 p-0 m-0 flex list-none flex-col">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{agents.map((agent) => (
|
||||
<motion.li
|
||||
key={agent.agent_id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<AgentRow agent={agent} />
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentPoolSectionProps {
|
||||
title: string;
|
||||
agents: Agent[];
|
||||
|
|
@ -108,46 +130,19 @@ export function AgentPoolSection({ title, agents }: AgentPoolSectionProps) {
|
|||
</div>
|
||||
);
|
||||
|
||||
const collapsedPreview =
|
||||
activeAgents.length > 0 ? (
|
||||
<ul className="p-0 m-0 space-y-0.5 list-none">
|
||||
<AnimatePresence initial={false}>
|
||||
{activeAgents.map((agent) => (
|
||||
<motion.li
|
||||
key={agent.agent_id}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<AgentRow agent={agent} />
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<SidePanelAccordionBox title={title} collapsedPreview={collapsedPreview}>
|
||||
{ordered.length === 0 ? (
|
||||
emptyState
|
||||
) : (
|
||||
<ul className="p-0 m-0 space-y-0.5 list-none">
|
||||
<AnimatePresence initial={false}>
|
||||
{ordered.map((agent) => (
|
||||
<motion.li
|
||||
key={agent.agent_id}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<AgentRow agent={agent} />
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
)}
|
||||
<SidePanelAccordionBox title={title} defaultOpen={false}>
|
||||
{({ open }) => {
|
||||
if (ordered.length === 0) {
|
||||
return open ? emptyState : null;
|
||||
}
|
||||
if (!open) {
|
||||
return activeAgents.length > 0 ? (
|
||||
<AgentList agents={activeAgents} />
|
||||
) : null;
|
||||
}
|
||||
return <AgentList agents={ordered} />;
|
||||
}}
|
||||
</SidePanelAccordionBox>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
CategoryLabel,
|
||||
SidePanelListRow,
|
||||
} from '@/components/SidePanelSections/primitives';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
|
@ -66,7 +67,11 @@ export function ContextSection({ title, items }: ContextSectionProps) {
|
|||
<div className="flex flex-col">
|
||||
{grouped.map(({ category, items: groupItems }) => (
|
||||
<div key={category} className="flex flex-col">
|
||||
<CategoryLabel>{CATEGORY_LABEL[category]}</CategoryLabel>
|
||||
<CategoryLabel
|
||||
className={cn(category === 'tool' && 'text-label-xs')}
|
||||
>
|
||||
{CATEGORY_LABEL[category]}
|
||||
</CategoryLabel>
|
||||
<motion.ul layout className="p-0 m-0 space-y-0.5 list-none">
|
||||
<AnimatePresence initial={false}>
|
||||
{groupItems.map((item) => (
|
||||
|
|
@ -81,6 +86,7 @@ export function ContextSection({ title, items }: ContextSectionProps) {
|
|||
<SidePanelListRow
|
||||
leading={item.icon}
|
||||
onClick={item.onClick}
|
||||
interactiveHover
|
||||
>
|
||||
{item.label}
|
||||
</SidePanelListRow>
|
||||
|
|
|
|||
|
|
@ -34,9 +34,9 @@ interface ProgressSectionProps {
|
|||
export function ProgressSection({ title, subtasks }: ProgressSectionProps) {
|
||||
const count = subtasks.length;
|
||||
|
||||
const collapsedPreview =
|
||||
const collapsedStrip =
|
||||
count > 0 ? (
|
||||
<div className="gap-1 min-w-0 flex items-center overflow-hidden">
|
||||
<div className="gap-1 min-w-0 mx-1 flex items-center overflow-hidden">
|
||||
<AnimatePresence initial={false}>
|
||||
{subtasks.map((task, idx) => (
|
||||
<motion.span
|
||||
|
|
@ -60,34 +60,42 @@ export function ProgressSection({ title, subtasks }: ProgressSectionProps) {
|
|||
<SidePanelAccordionBox
|
||||
title={title}
|
||||
titleSuffix={count > 0 ? <CountPill count={count} /> : null}
|
||||
collapsedPreview={collapsedPreview}
|
||||
>
|
||||
{count === 0 ? (
|
||||
<div className="text-ds-text-neutral-muted-default text-body-sm px-1 py-1">
|
||||
No subtasks yet
|
||||
</div>
|
||||
) : (
|
||||
<motion.ul layout className="p-0 m-0 space-y-0.5 list-none">
|
||||
<AnimatePresence initial={false}>
|
||||
{subtasks.map((task) => (
|
||||
<motion.li
|
||||
key={task.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<SidePanelListRow
|
||||
leading={<ProgressCircle done={isDone(task)} />}
|
||||
{({ open }) => {
|
||||
if (!open) {
|
||||
return collapsedStrip;
|
||||
}
|
||||
if (count === 0) {
|
||||
return (
|
||||
<div className="text-ds-text-neutral-muted-default text-body-sm px-1 py-1">
|
||||
No subtasks yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<motion.ul layout className="p-0 m-0 space-y-0.5 list-none">
|
||||
<AnimatePresence initial={false}>
|
||||
{subtasks.map((task) => (
|
||||
<motion.li
|
||||
key={task.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
{task.content}
|
||||
</SidePanelListRow>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.ul>
|
||||
)}
|
||||
<SidePanelListRow
|
||||
className="hover:bg-ds-bg-neutral-subtle-default cursor-pointer"
|
||||
leading={<ProgressCircle done={isDone(task)} />}
|
||||
>
|
||||
{task.content}
|
||||
</SidePanelListRow>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.ul>
|
||||
);
|
||||
}}
|
||||
</SidePanelAccordionBox>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,16 +15,105 @@
|
|||
import { getToolkitIcon } from '@/lib/toolkitIcons';
|
||||
import type { ContextItem } from './ContextSection';
|
||||
|
||||
function addHint(set: Set<string>, raw: string) {
|
||||
const t = raw.trim().toLowerCase();
|
||||
if (!t) return;
|
||||
set.add(t);
|
||||
const noToolkit = t.replace(/\s+toolkit\s*$/i, '').trim();
|
||||
if (noToolkit && noToolkit !== t) set.add(noToolkit);
|
||||
}
|
||||
|
||||
function collectWorkerHintSets(agents: Agent[]) {
|
||||
const skillHints = new Set<string>();
|
||||
const connectorHints = new Set<string>();
|
||||
|
||||
for (const agent of agents) {
|
||||
const info = agent.workerInfo;
|
||||
if (!info) continue;
|
||||
|
||||
const mcp: unknown = info.mcp_tools;
|
||||
if (mcp && typeof mcp === 'object') {
|
||||
const servers = (mcp as { mcpServers?: Record<string, unknown> })
|
||||
.mcpServers;
|
||||
if (servers && typeof servers === 'object') {
|
||||
for (const name of Object.keys(servers)) {
|
||||
addHint(connectorHints, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selected: unknown = info.selectedTools;
|
||||
if (!Array.isArray(selected)) continue;
|
||||
for (const raw of selected) {
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const item = raw as {
|
||||
name?: string;
|
||||
key?: string;
|
||||
toolkit?: string;
|
||||
category?: { name?: string };
|
||||
};
|
||||
const label = item.name ?? item.key ?? item.toolkit;
|
||||
if (!label) continue;
|
||||
const categoryName = item.category?.name?.toLowerCase() ?? '';
|
||||
const hints = categoryName === 'skill' ? skillHints : connectorHints;
|
||||
addHint(hints, label);
|
||||
if (item.toolkit) addHint(hints, item.toolkit);
|
||||
}
|
||||
}
|
||||
|
||||
return { skillHints, connectorHints };
|
||||
}
|
||||
|
||||
function runtimeCategoryForToolkit(
|
||||
toolkitName: string,
|
||||
skillHints: Set<string>,
|
||||
connectorHints: Set<string>
|
||||
): ContextItem['category'] {
|
||||
const tn = toolkitName.trim().toLowerCase();
|
||||
if (tn.includes('mcp')) return 'connector';
|
||||
if (skillHints.has(tn)) return 'skill';
|
||||
const noTk = tn.replace(/\s+toolkit\s*$/i, '').trim();
|
||||
if (skillHints.has(noTk)) return 'skill';
|
||||
if (connectorHints.has(tn)) return 'connector';
|
||||
if (connectorHints.has(noTk)) return 'connector';
|
||||
return 'tool';
|
||||
}
|
||||
|
||||
function forEachRuntimeToolkit(
|
||||
agents: Agent[],
|
||||
taskRunning: TaskInfo[] | undefined,
|
||||
fn: (toolkitName: string) => void
|
||||
) {
|
||||
for (const agent of agents) {
|
||||
for (const task of agent.tasks ?? []) {
|
||||
for (const tk of task.toolkits ?? []) {
|
||||
const name = tk.toolkitName;
|
||||
if (!name || name === 'notice') continue;
|
||||
fn(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const task of taskRunning ?? []) {
|
||||
for (const tk of task.toolkits ?? []) {
|
||||
const name = tk.toolkitName;
|
||||
if (!name || name === 'notice') continue;
|
||||
fn(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a flat, deduplicated list of context items (skills / connectors /
|
||||
* tools) from a set of agents' workerInfo.
|
||||
* tools) from agents' workerInfo **and** runtime toolkit usage on subtasks.
|
||||
*
|
||||
* - `workerInfo.tools: string[]` → category "tool"
|
||||
* - `workerInfo.mcp_tools.mcpServers: { [name]: config }` → category "connector"
|
||||
* - `workerInfo.selectedTools: McpItem[]` with `category.name` → category "skill"
|
||||
* (fallback to connector if category is not "skill")
|
||||
* - `workerInfo` → configured tools, connectors, skills (as before)
|
||||
* - `task.toolkits` / `taskRunning[].toolkits` from ACTIVATE_TOOLKIT → records
|
||||
* actual tool/skill/connector usage during the run (merged in, deduped)
|
||||
*/
|
||||
export function buildContextItems(agents: Agent[]): ContextItem[] {
|
||||
export function buildContextItems(
|
||||
agents: Agent[],
|
||||
taskRunning?: TaskInfo[]
|
||||
): ContextItem[] {
|
||||
const seen = new Set<string>();
|
||||
const out: ContextItem[] = [];
|
||||
|
||||
|
|
@ -35,6 +124,8 @@ export function buildContextItems(agents: Agent[]): ContextItem[] {
|
|||
out.push(item);
|
||||
};
|
||||
|
||||
const { skillHints, connectorHints } = collectWorkerHintSets(agents);
|
||||
|
||||
for (const agent of agents) {
|
||||
const info = agent.workerInfo;
|
||||
if (!info) continue;
|
||||
|
|
@ -93,5 +184,19 @@ export function buildContextItems(agents: Agent[]): ContextItem[] {
|
|||
}
|
||||
}
|
||||
|
||||
forEachRuntimeToolkit(agents, taskRunning, (toolkitName) => {
|
||||
const category = runtimeCategoryForToolkit(
|
||||
toolkitName,
|
||||
skillHints,
|
||||
connectorHints
|
||||
);
|
||||
push({
|
||||
id: toolkitName,
|
||||
label: toolkitName,
|
||||
category,
|
||||
icon: getToolkitIcon(toolkitName, 16),
|
||||
});
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
// ========= 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. =========
|
||||
|
||||
/**
|
||||
* Output files from agent runs are stored on each plan subtask under
|
||||
* `taskAssigning[].tasks[].fileList` (see `addFileList` on WRITE_FILE).
|
||||
* The chat task's top-level `fileList` is not kept in sync, so the side
|
||||
* panel must aggregate from assigning agents.
|
||||
*/
|
||||
export function collectSidePanelOutputFiles(
|
||||
task:
|
||||
| {
|
||||
taskAssigning?: Agent[];
|
||||
fileList?: FileInfo[];
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
): FileInfo[] {
|
||||
if (!task) return [];
|
||||
const nested = (task.taskAssigning ?? []).flatMap((agent) =>
|
||||
agent.tasks.flatMap((t) => t.fileList ?? [])
|
||||
);
|
||||
const top = task.fileList ?? [];
|
||||
return [...top, ...nested];
|
||||
}
|
||||
|
|
@ -30,9 +30,20 @@ export function CountPill({ count }: { count: number }) {
|
|||
/**
|
||||
* Small muted category label for grouping list items.
|
||||
*/
|
||||
export function CategoryLabel({ children }: { children: ReactNode }) {
|
||||
export function CategoryLabel({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="text-ds-text-neutral-muted-default text-body-sm px-1 pb-1 pt-2 first:pt-0">
|
||||
<div
|
||||
className={cn(
|
||||
'text-ds-text-neutral-muted-default text-body-sm px-1 pb-1 pt-2 first:pt-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -44,6 +55,11 @@ type SidePanelListRowProps = {
|
|||
trailing?: ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
/**
|
||||
* Pointer + subtle hover/active backgrounds without an action (e.g. read-only list rows).
|
||||
* When `onClick` is set, focus ring is included; for hover-only rows it is omitted.
|
||||
*/
|
||||
interactiveHover?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
|
|
@ -52,15 +68,31 @@ type SidePanelListRowProps = {
|
|||
* Rendered as a button when `onClick` is provided, otherwise a div.
|
||||
*/
|
||||
export const SidePanelListRow = forwardRef<HTMLElement, SidePanelListRowProps>(
|
||||
({ leading, children, trailing, disabled, onClick, className }, ref) => {
|
||||
(
|
||||
{
|
||||
leading,
|
||||
children,
|
||||
trailing,
|
||||
disabled,
|
||||
onClick,
|
||||
interactiveHover,
|
||||
className,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const showAffordance = Boolean(onClick || interactiveHover);
|
||||
const base = cn(
|
||||
'group gap-2 px-1.5 py-1.5 rounded-md min-w-0 w-full flex items-center',
|
||||
'text-ds-text-neutral-default-default text-body-sm text-left',
|
||||
'transition-colors',
|
||||
disabled
|
||||
? 'opacity-50 pointer-events-none'
|
||||
: onClick
|
||||
? 'hover:bg-ds-bg-neutral-default-hover cursor-pointer'
|
||||
: showAffordance
|
||||
? cn(
|
||||
'cursor-pointer hover:bg-ds-bg-neutral-subtle-default active:bg-ds-bg-neutral-subtle-hover',
|
||||
onClick &&
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-brand-default-focus/40'
|
||||
)
|
||||
: '',
|
||||
className
|
||||
);
|
||||
|
|
@ -101,9 +133,8 @@ export const SidePanelListRow = forwardRef<HTMLElement, SidePanelListRowProps>(
|
|||
SidePanelListRow.displayName = 'SidePanelListRow';
|
||||
|
||||
/**
|
||||
* Progress circle. `done` shows a filled circle with a check icon; otherwise
|
||||
* renders an empty outlined circle (all non-done states share the empty look,
|
||||
* per design spec).
|
||||
* Progress circle. Incomplete: neutral subtle fill so the ring reads on any
|
||||
* panel background. Complete: success subtle fill, strong border and check.
|
||||
*/
|
||||
export function ProgressCircle({
|
||||
done,
|
||||
|
|
@ -115,15 +146,15 @@ export function ProgressCircle({
|
|||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-full border',
|
||||
'inline-flex shrink-0 items-center justify-center rounded-full border border-solid',
|
||||
done
|
||||
? 'bg-ds-bg-status-completed-default-default border-ds-border-status-completed-default-default text-ds-text-brand-inverse-default'
|
||||
: 'border-ds-border-neutral-default-default bg-transparent text-transparent'
|
||||
? 'bg-ds-bg-success-subtle-default border-ds-border-success-strong-default text-ds-text-success-strong-default'
|
||||
: 'border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default'
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
aria-hidden
|
||||
>
|
||||
{done ? <Check size={Math.max(8, size - 6)} strokeWidth={3} /> : null}
|
||||
{done ? <Check size={Math.max(8, size - 6)} strokeWidth={4} /> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@ import { AgentFolderSection } from '@/components/SidePanelSections/AgentFolderSe
|
|||
import { ContextSection } from '@/components/SidePanelSections/ContextSection';
|
||||
import { ProgressSection } from '@/components/SidePanelSections/ProgressSection';
|
||||
import { buildContextItems } from '@/components/SidePanelSections/buildContextItems';
|
||||
import { collectSidePanelOutputFiles } from '@/components/SidePanelSections/collectSidePanelOutputFiles';
|
||||
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useMemo } from 'react';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface SingleAgentSidePanelProps {
|
||||
|
|
@ -31,16 +33,44 @@ export function SingleAgentSidePanel({
|
|||
onToggleSidePanel: _onToggleSidePanel,
|
||||
}: SingleAgentSidePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { chatStore } = useChatStoreAdapter();
|
||||
const { chatStore, projectStore } = useChatStoreAdapter();
|
||||
const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab);
|
||||
|
||||
const activeTask = chatStore?.activeTaskId
|
||||
? chatStore.tasks[chatStore.activeTaskId]
|
||||
: undefined;
|
||||
|
||||
const agents = activeTask?.taskAssigning ?? [];
|
||||
const subtasks = agents[0]?.tasks ?? activeTask?.taskInfo ?? [];
|
||||
const files = activeTask?.fileList ?? [];
|
||||
const contextItems = useMemo(() => buildContextItems(agents), [agents]);
|
||||
/** Prefer live `taskRunning` status (updated on TASK_STATE), keep plan order/text from agent tasks or taskInfo. */
|
||||
const subtasks = useMemo(() => {
|
||||
const base = agents[0]?.tasks ?? activeTask?.taskInfo ?? [];
|
||||
const taskRunning = activeTask?.taskRunning ?? [];
|
||||
if (taskRunning.length === 0) return base;
|
||||
return base.map((t) => {
|
||||
const live = taskRunning.find((r) => r.id === t.id);
|
||||
if (!live) return t;
|
||||
return { ...t, ...live, content: t.content || live.content };
|
||||
});
|
||||
}, [agents, activeTask?.taskInfo, activeTask?.taskRunning]);
|
||||
const files = useMemo(
|
||||
() => collectSidePanelOutputFiles(activeTask),
|
||||
[activeTask]
|
||||
);
|
||||
const contextItems = useMemo(
|
||||
() => buildContextItems(agents, activeTask?.taskRunning),
|
||||
[agents, activeTask?.taskRunning]
|
||||
);
|
||||
|
||||
const handleOpenAgentFile = useCallback(
|
||||
(file: FileInfo) => {
|
||||
if (!chatStore?.activeTaskId) return;
|
||||
chatStore.setSelectedFile(chatStore.activeTaskId, file);
|
||||
setActiveWorkspaceTab('inbox', {
|
||||
clearInboxForProjectId: projectStore.activeProjectId ?? null,
|
||||
});
|
||||
},
|
||||
[chatStore, projectStore.activeProjectId, setActiveWorkspaceTab]
|
||||
);
|
||||
|
||||
if (!isSidePanelVisible) {
|
||||
return null;
|
||||
|
|
@ -73,6 +103,7 @@ export function SingleAgentSidePanel({
|
|||
defaultValue: 'Agent Folder',
|
||||
})}
|
||||
files={files}
|
||||
onOpenFile={handleOpenAgentFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
||||
import { CircleCheckBig, CircleSlash2, LoaderCircle } from 'lucide-react';
|
||||
import {
|
||||
Circle,
|
||||
CircleCheckBig,
|
||||
CircleSlash2,
|
||||
LoaderCircle,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type TaskStateType =
|
||||
|
|
@ -74,7 +79,7 @@ export const TaskState = ({
|
|||
{/* All */}
|
||||
{all && (forceVisible || all > 0) ? (
|
||||
<div
|
||||
className={`group gap-xs px-2 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
|
||||
className={`group gap-xs rounded-md px-2 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
|
||||
isSelected('all')
|
||||
? 'bg-ds-bg-neutral-subtle-default'
|
||||
: 'bg-transparent'
|
||||
|
|
@ -90,7 +95,7 @@ export const TaskState = ({
|
|||
{/* Done */}
|
||||
{done && (forceVisible || done > 0) ? (
|
||||
<div
|
||||
className={`group gap-xs px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
|
||||
className={`group gap-xs rounded-md px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
|
||||
isSelected('done') && 'bg-ds-bg-neutral-subtle-default'
|
||||
} ${
|
||||
clickable && 'cursor-pointer transition-opacity hover:opacity-80'
|
||||
|
|
@ -117,7 +122,7 @@ export const TaskState = ({
|
|||
{/* Reassigned */}
|
||||
{reAssignTo && (forceVisible || reAssignTo > 0) ? (
|
||||
<div
|
||||
className={`group gap-xs px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
|
||||
className={`group gap-xs rounded-md px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
|
||||
isSelected('reassigned') && 'bg-ds-bg-neutral-subtle-default'
|
||||
} ${
|
||||
clickable && 'cursor-pointer transition-opacity hover:opacity-80'
|
||||
|
|
@ -145,7 +150,7 @@ export const TaskState = ({
|
|||
{/* Ongoing */}
|
||||
{progress && (forceVisible || progress > 0) ? (
|
||||
<div
|
||||
className={`group gap-xs px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center ${
|
||||
className={`group gap-xs rounded-md px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center ${
|
||||
isSelected('ongoing') && 'bg-ds-bg-neutral-subtle-default'
|
||||
} ${
|
||||
clickable && 'cursor-pointer transition-opacity hover:opacity-80'
|
||||
|
|
@ -176,7 +181,7 @@ export const TaskState = ({
|
|||
{/* Failed */}
|
||||
{failed && (forceVisible || failed > 0) ? (
|
||||
<div
|
||||
className={`group gap-xs px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
|
||||
className={`group gap-xs rounded-md px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
|
||||
isSelected('failed') && 'bg-ds-bg-neutral-subtle-default'
|
||||
} ${
|
||||
clickable && 'cursor-pointer transition-opacity hover:opacity-80'
|
||||
|
|
@ -202,16 +207,16 @@ export const TaskState = ({
|
|||
{/* Pending */}
|
||||
{skipped && (forceVisible || skipped > 0) ? (
|
||||
<div
|
||||
className={`group gap-xs px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center ${
|
||||
className={`group gap-xs rounded-md px-0.5 py-0.5 hover:bg-ds-bg-status-pending-subtle-hover flex items-center ${
|
||||
isSelected('pending')
|
||||
? 'bg-ds-bg-neutral-subtle-default'
|
||||
? 'bg-ds-bg-status-pending-subtle-default'
|
||||
: 'bg-transparent'
|
||||
} ${
|
||||
clickable && 'cursor-pointer transition-opacity hover:opacity-80'
|
||||
}`}
|
||||
onClick={() => handleStateClick('pending')}
|
||||
>
|
||||
<LoaderCircle
|
||||
<Circle
|
||||
className={`text-ds-icon-neutral-muted-default group-hover:text-ds-icon-status-pending-default-default h-[10px] w-[10px] ${
|
||||
(isSelected('pending') || forceVisible) &&
|
||||
'text-ds-icon-status-pending-default-default'
|
||||
|
|
|
|||
|
|
@ -41,11 +41,11 @@ import {
|
|||
Download,
|
||||
House,
|
||||
Minus,
|
||||
Plus,
|
||||
Settings,
|
||||
Share,
|
||||
Sparkles,
|
||||
Square,
|
||||
SquarePen,
|
||||
TagIcon,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
|
@ -126,8 +126,12 @@ function HeaderWin() {
|
|||
const location = useLocation();
|
||||
const { canGoBack, canGoForward } = useStackNavigationBounds();
|
||||
//Get Chatstore for the active project's task
|
||||
const { chatStore, projectStore } = useChatStoreAdapter();
|
||||
const { chatStore } = useChatStoreAdapter();
|
||||
const { chatPanelPosition, setChatPanelPosition } = usePageTabStore();
|
||||
const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab);
|
||||
const requestWorkspaceChatFocus = usePageTabStore(
|
||||
(s) => s.requestWorkspaceChatFocus
|
||||
);
|
||||
const historySidebarOpen = useSidebarStore((s) => s.isOpen);
|
||||
const toggleHistorySidebar = useSidebarStore((s) => s.toggle);
|
||||
const appearance = useAuthStore((state) => state.appearance);
|
||||
|
|
@ -182,10 +186,18 @@ function HeaderWin() {
|
|||
return path === '/history' || path.endsWith('/history');
|
||||
}, [location.pathname]);
|
||||
|
||||
const createNewProject = () => {
|
||||
projectStore.createProject('new project');
|
||||
navigate('/');
|
||||
};
|
||||
const openWorkspaceNewTask = useCallback(() => {
|
||||
if (location.pathname !== '/') {
|
||||
navigate('/');
|
||||
}
|
||||
setActiveWorkspaceTab('workforce');
|
||||
requestWorkspaceChatFocus();
|
||||
}, [
|
||||
location.pathname,
|
||||
navigate,
|
||||
requestWorkspaceChatFocus,
|
||||
setActiveWorkspaceTab,
|
||||
]);
|
||||
|
||||
const summaryTask =
|
||||
chatStore?.tasks[chatStore?.activeTaskId as string]?.summaryTask;
|
||||
|
|
@ -354,42 +366,48 @@ function HeaderWin() {
|
|||
</TooltipSimple>
|
||||
</div>
|
||||
{location.pathname === '/' && (
|
||||
<div className="no-drag ease-out animate-in fade-in-0 inline-flex items-stretch overflow-hidden rounded-full duration-200">
|
||||
<div className="no-drag gap-1 inline-flex items-center">
|
||||
<div className="ease-out animate-in fade-in-0 inline-flex items-stretch overflow-hidden rounded-full duration-200">
|
||||
<TooltipSimple
|
||||
content={
|
||||
activeTaskTitle === t('layout.new-project')
|
||||
? t('layout.new-project')
|
||||
: activeTaskTitle
|
||||
}
|
||||
side="bottom"
|
||||
align="center"
|
||||
>
|
||||
<button
|
||||
id="active-task-title-btn"
|
||||
type="button"
|
||||
className="no-drag min-w-0 px-2 text-label-sm font-bold focus-visible:ring-ds-ring-brand-default-focus/50 !text-ds-text-neutral-default-default hover:bg-ds-bg-neutral-default-hover active:bg-ds-bg-neutral-default-active flex min-h-[28px] max-w-[300px] flex-1 items-center text-left outline-none focus-visible:ring-[3px]"
|
||||
onClick={toggleHistorySidebar}
|
||||
aria-expanded={historySidebarOpen}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{activeTaskTitle}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
<TooltipSimple
|
||||
content={
|
||||
activeTaskTitle === t('layout.new-project')
|
||||
? t('layout.new-project')
|
||||
: activeTaskTitle
|
||||
}
|
||||
content={t('layout.add-new-task')}
|
||||
side="bottom"
|
||||
align="center"
|
||||
>
|
||||
<button
|
||||
id="active-task-title-btn"
|
||||
type="button"
|
||||
className="no-drag min-w-0 px-2 text-label-sm font-bold focus-visible:ring-ds-ring-brand-default-focus/50 !text-ds-text-neutral-default-default hover:bg-ds-bg-neutral-default-hover active:bg-ds-bg-neutral-default-active flex min-h-[28px] max-w-[300px] flex-1 items-center text-left outline-none focus-visible:ring-[3px]"
|
||||
onClick={toggleHistorySidebar}
|
||||
aria-expanded={historySidebarOpen}
|
||||
aria-haspopup="dialog"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="no-drag rounded-full"
|
||||
onClick={openWorkspaceNewTask}
|
||||
aria-label={t('layout.add-new-task')}
|
||||
>
|
||||
<span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{activeTaskTitle}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
<TooltipSimple
|
||||
content={t('layout.new-project')}
|
||||
side="bottom"
|
||||
align="center"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="no-drag w-8 focus-visible:ring-ds-ring-brand-default-focus/50 !text-ds-text-neutral-default-default hover:bg-ds-bg-neutral-default-hover active:bg-ds-bg-neutral-default-active box-border flex min-h-[28px] shrink-0 items-center justify-center outline-none focus-visible:ring-[3px]"
|
||||
onClick={createNewProject}
|
||||
aria-label={t('layout.new-project')}
|
||||
>
|
||||
<Plus className="h-4 w-4 shrink-0" aria-hidden />
|
||||
</button>
|
||||
<SquarePen
|
||||
className="h-4 w-4 text-[color:var(--ds-icon-neutral-default-default)]"
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -480,13 +480,10 @@ export const TriggerDialog: React.FC<TriggerDialogProps> = ({
|
|||
}}
|
||||
className="rounded-2xl bg-ds-bg-neutral-muted-disabled w-full"
|
||||
>
|
||||
<TabsList
|
||||
variant="outline"
|
||||
className="rounded-t-2xl border-ds-border-neutral-default-default px-4 w-full border-x-0 border-t-0 border-b-[0.5px] border-solid"
|
||||
>
|
||||
<TabsList variant="default" className="w-full">
|
||||
<TabsTrigger
|
||||
value="schedule"
|
||||
className="flex-1"
|
||||
className="text-body-sm gap-2 flex-1"
|
||||
disabled={!!selectedTrigger}
|
||||
>
|
||||
<AlarmClockIcon className="h-4 w-4" />
|
||||
|
|
@ -494,7 +491,7 @@ export const TriggerDialog: React.FC<TriggerDialogProps> = ({
|
|||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="app"
|
||||
className="flex-1"
|
||||
className="text-body-sm gap-2 flex-1"
|
||||
disabled={!!selectedTrigger}
|
||||
>
|
||||
<CableIcon className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -32,6 +32,21 @@ export interface AgentDisplayInfo {
|
|||
bgColorLight: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classes for the small top-right role badge on agent tiles. Must be full literal
|
||||
* strings (including `!`) so Tailwind emits them, and `!text-*` beats
|
||||
* `button .lucide` in `src/style/index.css`.
|
||||
*/
|
||||
export const WORKFLOW_AGENT_SUB_ICON_CLASS: Record<WorkflowAgentType, string> =
|
||||
{
|
||||
developer_agent:
|
||||
'!h-[10px] !w-[10px] shrink-0 !text-ds-text-terminal-default-default',
|
||||
browser_agent: '!h-[10px] !w-[10px] shrink-0 !text-blue-700',
|
||||
document_agent: '!h-[10px] !w-[10px] shrink-0 !text-yellow-700',
|
||||
multi_modal_agent: '!h-[10px] !w-[10px] shrink-0 !text-fuchsia-700',
|
||||
social_media_agent: '!h-[10px] !w-[10px] shrink-0 !text-purple-700',
|
||||
};
|
||||
|
||||
export const agentMap: Record<WorkflowAgentType, AgentDisplayInfo> = {
|
||||
developer_agent: {
|
||||
name: 'Developer Agent',
|
||||
|
|
|
|||
|
|
@ -635,7 +635,11 @@ export function Node({ id, data }: NodeProps) {
|
|||
? 'bg-ds-bg-status-error-subtle-default hover:bg-ds-bg-status-error-subtle-hover'
|
||||
: task.status === TaskStatus.RUNNING
|
||||
? 'bg-ds-bg-status-running-subtle-default hover:bg-ds-bg-status-running-subtle-hover'
|
||||
: 'bg-ds-bg-status-running-subtle-default hover:bg-ds-bg-status-running-subtle-hover';
|
||||
: task.status === TaskStatus.SKIPPED ||
|
||||
task.status === TaskStatus.WAITING ||
|
||||
task.status === TaskStatus.EMPTY
|
||||
? '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'
|
||||
: task.status === TaskStatus.COMPLETED
|
||||
|
|
@ -646,12 +650,11 @@ export function Node({ id, data }: NodeProps) {
|
|||
? 'text-ds-text-status-running-default-default'
|
||||
: task.status === TaskStatus.BLOCKED
|
||||
? 'text-ds-text-status-blocked-default-default'
|
||||
: task.status === TaskStatus.SKIPPED
|
||||
? 'text-ds-text-status-skipped-default-default'
|
||||
: task.status === TaskStatus.WAITING ||
|
||||
task.status === TaskStatus.EMPTY
|
||||
? 'text-ds-text-status-pending-default-default'
|
||||
: 'text-ds-text-status-running-default-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';
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
|
|
@ -691,7 +694,11 @@ export function Node({ id, data }: NodeProps) {
|
|||
? '!border-ds-border-neutral-subtle-focus'
|
||||
: task.status === TaskStatus.BLOCKED
|
||||
? '!border-ds-border-status-blocked-subtle-focus'
|
||||
: '!border-ds-border-neutral-subtle-focus'
|
||||
: task.status === TaskStatus.SKIPPED ||
|
||||
task.status === TaskStatus.WAITING ||
|
||||
task.status === TaskStatus.EMPTY
|
||||
? '!border-ds-border-status-pending-default-hover'
|
||||
: '!border-ds-border-neutral-subtle-focus'
|
||||
: 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -719,7 +726,7 @@ export function Node({ id, data }: NodeProps) {
|
|||
{task.status === TaskStatus.SKIPPED && (
|
||||
<LoaderCircle
|
||||
size={16}
|
||||
className="text-ds-icon-status-skipped-default-default"
|
||||
className="text-ds-icon-status-pending-default-default"
|
||||
/>
|
||||
)}
|
||||
{task.status === TaskStatus.COMPLETED && (
|
||||
|
|
|
|||
|
|
@ -372,7 +372,11 @@ export function AgentDetailPane({
|
|||
? 'bg-ds-bg-status-running-subtle-default'
|
||||
: task.status === TaskStatus.BLOCKED
|
||||
? 'bg-ds-bg-status-blocked-subtle-default'
|
||||
: 'bg-ds-bg-status-running-subtle-default',
|
||||
: task.status === TaskStatus.SKIPPED ||
|
||||
task.status === TaskStatus.WAITING ||
|
||||
task.status === TaskStatus.EMPTY
|
||||
? 'bg-ds-bg-status-pending-subtle-default'
|
||||
: 'bg-ds-bg-status-running-subtle-default',
|
||||
task.status === TaskStatus.COMPLETED
|
||||
? 'hover:border-ds-border-status-completed-default-focus'
|
||||
: task.status === TaskStatus.FAILED
|
||||
|
|
@ -381,7 +385,11 @@ export function AgentDetailPane({
|
|||
? 'hover:border-ds-border-neutral-strong-default'
|
||||
: task.status === TaskStatus.BLOCKED
|
||||
? 'hover:border-ds-border-status-blocked-default-focus'
|
||||
: 'hover:border-ds-border-neutral-default-focus',
|
||||
: task.status === TaskStatus.SKIPPED ||
|
||||
task.status === TaskStatus.WAITING ||
|
||||
task.status === TaskStatus.EMPTY
|
||||
? 'hover:border-ds-border-status-pending-default-hover'
|
||||
: 'hover:border-ds-border-neutral-default-focus',
|
||||
'border-transparent'
|
||||
)}
|
||||
>
|
||||
|
|
@ -410,7 +418,7 @@ export function AgentDetailPane({
|
|||
{task.status === TaskStatus.SKIPPED && (
|
||||
<LoaderCircle
|
||||
size={16}
|
||||
className="text-ds-icon-neutral-muted-default"
|
||||
className="text-ds-icon-status-pending-default-default"
|
||||
/>
|
||||
)}
|
||||
{task.status === TaskStatus.COMPLETED && (
|
||||
|
|
@ -435,7 +443,7 @@ export function AgentDetailPane({
|
|||
task.status === TaskStatus.WAITING) && (
|
||||
<Circle
|
||||
size={16}
|
||||
className="text-ds-icon-neutral-muted-default"
|
||||
className="text-ds-icon-status-pending-default-default"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
import { AgentFolderSection } from '@/components/SidePanelSections/AgentFolderSection';
|
||||
import { AgentPoolSection } from '@/components/SidePanelSections/AgentPoolSection';
|
||||
import { buildContextItems } from '@/components/SidePanelSections/buildContextItems';
|
||||
import { collectSidePanelOutputFiles } from '@/components/SidePanelSections/collectSidePanelOutputFiles';
|
||||
import { ContextSection } from '@/components/SidePanelSections/ContextSection';
|
||||
import { ProgressSection } from '@/components/SidePanelSections/ProgressSection';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -22,8 +23,9 @@ import { TooltipSimple } from '@/components/ui/tooltip';
|
|||
import ExpandedOverlay from '@/components/Workforce/ExpandedOverlay';
|
||||
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
import { Maximize2, X } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const WORKFORCE_MAIN_SURFACE_CLASS =
|
||||
|
|
@ -50,15 +52,43 @@ export function WorkforceSidePanel({
|
|||
onCloseExpandedOverlay,
|
||||
}: WorkforceSidePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { chatStore } = useChatStoreAdapter();
|
||||
const { chatStore, projectStore } = useChatStoreAdapter();
|
||||
const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab);
|
||||
const activeTask = chatStore?.activeTaskId
|
||||
? chatStore.tasks[chatStore.activeTaskId]
|
||||
: undefined;
|
||||
|
||||
const agents = activeTask?.taskAssigning ?? [];
|
||||
const subtasks = activeTask?.taskInfo ?? [];
|
||||
const files = activeTask?.fileList ?? [];
|
||||
const contextItems = useMemo(() => buildContextItems(agents), [agents]);
|
||||
/** Subtask status is updated in `taskRunning` (e.g. TASK_STATE); `taskInfo` keeps plan text/order. */
|
||||
const subtasks = useMemo(() => {
|
||||
const taskInfo = activeTask?.taskInfo ?? [];
|
||||
const taskRunning = activeTask?.taskRunning ?? [];
|
||||
if (taskRunning.length === 0) return taskInfo;
|
||||
return taskInfo.map((t) => {
|
||||
const live = taskRunning.find((r) => r.id === t.id);
|
||||
if (!live) return t;
|
||||
return { ...t, ...live, content: t.content || live.content };
|
||||
});
|
||||
}, [activeTask?.taskInfo, activeTask?.taskRunning]);
|
||||
const files = useMemo(
|
||||
() => collectSidePanelOutputFiles(activeTask),
|
||||
[activeTask]
|
||||
);
|
||||
const contextItems = useMemo(
|
||||
() => buildContextItems(agents, activeTask?.taskRunning),
|
||||
[agents, activeTask?.taskRunning]
|
||||
);
|
||||
|
||||
const handleOpenAgentFile = useCallback(
|
||||
(file: FileInfo) => {
|
||||
if (!chatStore?.activeTaskId) return;
|
||||
chatStore.setSelectedFile(chatStore.activeTaskId, file);
|
||||
setActiveWorkspaceTab('inbox', {
|
||||
clearInboxForProjectId: projectStore.activeProjectId ?? null,
|
||||
});
|
||||
},
|
||||
[chatStore, projectStore.activeProjectId, setActiveWorkspaceTab]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -130,6 +160,7 @@ export function WorkforceSidePanel({
|
|||
defaultValue: 'Agent Folder',
|
||||
})}
|
||||
files={files}
|
||||
onOpenFile={handleOpenAgentFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@ import {
|
|||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { TooltipSimple } from '@/components/ui/tooltip';
|
||||
import { agentMap, type WorkflowAgentType } from '@/components/WorkFlow/agents';
|
||||
import {
|
||||
agentMap,
|
||||
WORKFLOW_AGENT_SUB_ICON_CLASS,
|
||||
type WorkflowAgentType,
|
||||
} from '@/components/WorkFlow/agents';
|
||||
import { getAgentToolkitLabels } from '@/components/WorkFlow/agentToolkitLabels';
|
||||
import { BASE_WORKFLOW_AGENTS } from '@/components/WorkFlow/baseWorkers';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -50,9 +54,8 @@ import { useTranslation } from 'react-i18next';
|
|||
/** Sub icons aligned with `WorkforceMenu` / `ui/menu-button` → `MenuToggleItem` (top-right badge, 10px). */
|
||||
function getWorkforceMenuStyleSubIcon(agentType: string): ReactNode {
|
||||
const key = agentType as WorkflowAgentType;
|
||||
if (!agentMap[key]) return null;
|
||||
const textColor = agentMap[key].textColor;
|
||||
const iconClass = cn('!h-[10px] !w-[10px] shrink-0', textColor);
|
||||
const iconClass = WORKFLOW_AGENT_SUB_ICON_CLASS[key];
|
||||
if (!iconClass) return null;
|
||||
switch (key) {
|
||||
case 'developer_agent':
|
||||
return <CodeXml className={iconClass} />;
|
||||
|
|
|
|||
|
|
@ -149,6 +149,9 @@ export default function Workspace() {
|
|||
const attachesToSend =
|
||||
JSON.parse(JSON.stringify(chatStore.tasks[taskId]?.attaches)) || [];
|
||||
|
||||
// Enter the live session immediately; task startup continues in the background.
|
||||
setActiveWorkspaceTab('session');
|
||||
|
||||
try {
|
||||
await chatStore.startTask(
|
||||
taskId,
|
||||
|
|
@ -162,8 +165,8 @@ export default function Workspace() {
|
|||
chatStore.setHasWaitComfirm(taskId, true);
|
||||
chatStore.setAttaches(taskId, []);
|
||||
setMessage('');
|
||||
setActiveWorkspaceTab('session');
|
||||
} catch (err: unknown) {
|
||||
setActiveWorkspaceTab('workforce');
|
||||
console.error('Failed to start task:', err);
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
|
|
|
|||
174
src/components/ui/animate-ui/icons/clipboard-list.tsx
Normal file
174
src/components/ui/animate-ui/icons/clipboard-list.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// ========= 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. =========
|
||||
|
||||
'use client';
|
||||
|
||||
import { motion, type Variants } from 'motion/react';
|
||||
|
||||
import {
|
||||
getVariants,
|
||||
IconWrapper,
|
||||
useAnimateIconContext,
|
||||
type IconProps,
|
||||
} from '@/components/ui/animate-ui/icons/icon';
|
||||
|
||||
type ClipboardListProps = IconProps<keyof typeof animations>;
|
||||
|
||||
const animations = {
|
||||
default: {
|
||||
rect: {},
|
||||
path1: {},
|
||||
path2: {
|
||||
initial: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
},
|
||||
animate: {
|
||||
pathLength: [0, 1],
|
||||
opacity: [0, 1],
|
||||
scale: [1.1, 1],
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
},
|
||||
path3: {
|
||||
initial: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
},
|
||||
animate: {
|
||||
pathLength: [0, 1],
|
||||
opacity: [0, 1],
|
||||
scale: [1.1, 1],
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: 'easeInOut',
|
||||
delay: 0.2,
|
||||
},
|
||||
},
|
||||
},
|
||||
path4: {
|
||||
initial: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
},
|
||||
animate: {
|
||||
pathLength: [0, 1],
|
||||
opacity: [0, 1],
|
||||
scale: [1.1, 1],
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: 'easeInOut',
|
||||
delay: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
path5: {
|
||||
initial: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
},
|
||||
animate: {
|
||||
pathLength: [0, 1],
|
||||
opacity: [0, 1],
|
||||
scale: [1.1, 1],
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: 'easeInOut',
|
||||
delay: 0.7,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, Variants>,
|
||||
} as const;
|
||||
|
||||
function IconComponent({ size, ...props }: ClipboardListProps) {
|
||||
const { controls } = useAnimateIconContext();
|
||||
const variants = getVariants(animations);
|
||||
|
||||
return (
|
||||
<motion.svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<motion.rect
|
||||
width="8"
|
||||
height="4"
|
||||
x="8"
|
||||
y="2"
|
||||
rx="1"
|
||||
ry="1"
|
||||
variants={variants.rect}
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
/>
|
||||
<motion.path
|
||||
d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"
|
||||
variants={variants.path1}
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
/>
|
||||
<motion.path
|
||||
d="M8 11h.01"
|
||||
variants={variants.path2}
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
/>
|
||||
<motion.path
|
||||
d="M12 11h4"
|
||||
variants={variants.path3}
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
/>
|
||||
<motion.path
|
||||
d="M8 16h.01"
|
||||
variants={variants.path4}
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
/>
|
||||
<motion.path
|
||||
d="M12 16h4"
|
||||
variants={variants.path5}
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ClipboardList(props: ClipboardListProps) {
|
||||
return <IconWrapper icon={IconComponent} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
animations,
|
||||
ClipboardList,
|
||||
ClipboardList as ClipboardListIcon,
|
||||
type ClipboardListProps as ClipboardListIconProps,
|
||||
type ClipboardListProps,
|
||||
};
|
||||
|
|
@ -343,7 +343,7 @@ const INVERSE = [
|
|||
].join(' ');
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center whitespace-nowrap border border-solid transition-all duration-200 ease-in-out disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 outline-none focus-visible:border-ds-border-brand-default-focus focus-visible:ring-ds-ring-brand-default-focus/50 focus-visible:ring-[3px] aria-invalid:ring-ds-ring-error-default-default/20 aria-invalid:border-ds-border-status-error-default-default shrink-0 cursor-pointer',
|
||||
'inline-flex items-center whitespace-nowrap border border-solid transition-all duration-200 ease-in-out disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:!text-inherit outline-none focus-visible:border-ds-border-brand-default-focus focus-visible:ring-ds-ring-brand-default-focus/50 focus-visible:ring-[3px] aria-invalid:ring-ds-ring-error-default-default/20 aria-invalid:border-ds-border-status-error-default-default shrink-0 cursor-pointer',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ const Switch = React.forwardRef<
|
|||
>(({ className, size = 'default', style, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer focus-visible:ring-ds-ring-brand-default-focus focus-visible:ring-offset-ds-bg-neutral-subtle-default data-[state=checked]:bg-ds-bg-status-completed-default-default data-[state=unchecked]:bg-ds-bg-neutral-default-default inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'peer focus-visible:ring-ds-ring-brand-default-focus focus-visible:ring-offset-ds-bg-neutral-subtle-default data-[state=checked]:bg-ds-bg-status-completed-default-default data-[state=unchecked]:bg-ds-bg-neutral-subtle-default shadow-sm inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
sizeClasses[size].root,
|
||||
className
|
||||
)}
|
||||
|
|
@ -53,7 +53,7 @@ const Switch = React.forwardRef<
|
|||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'bg-ds-text-brand-inverse-default data-[state=unchecked]:translate-x-0 pointer-events-none block rounded-full shadow-none ring-0 transition-transform',
|
||||
'bg-ds-text-brand-inverse-default data-[state=unchecked]:translate-x-0 shadow-lg pointer-events-none block rounded-full ring-0 transition-transform',
|
||||
sizeClasses[size].thumb
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ import * as React from 'react';
|
|||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type TabsVariant = 'default' | 'outline' | 'border';
|
||||
|
||||
// Context for variant
|
||||
const TabsContext = React.createContext<{ variant?: 'default' | 'outline' }>({
|
||||
const TabsContext = React.createContext<{ variant?: TabsVariant }>({
|
||||
variant: 'default',
|
||||
});
|
||||
|
||||
|
|
@ -27,52 +29,88 @@ const TabsContext = React.createContext<{ variant?: 'default' | 'outline' }>({
|
|||
const tabsTriggerClassName =
|
||||
'ring-offset-ds-bg-neutral-subtle-default focus-visible:ring-ds-ring-brand-default-focus gap-1 rounded-xl bg-ds-bg-neutral-strong-default px-2 py-1 text-body-sm font-semibold text-ds-text-neutral-default-default data-[state=active]:bg-ds-bg-neutral-subtle-default data-[state=active]:text-ds-text-neutral-default-default data-[state=active]:shadow-sm inline-flex items-center justify-center whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:text-ds-icon-neutral-default-default';
|
||||
|
||||
/**
|
||||
* Transparent triggers + hover chip (HistoryTabsNav); active selection is shown
|
||||
* by the animated bar under the tab row (TabsList), not a border on the trigger.
|
||||
*/
|
||||
const tabsTriggerBorderClassName =
|
||||
'ring-offset-ds-bg-neutral-default-default focus-visible:ring-ds-ring-brand-default-focus inline-flex h-8 min-h-8 shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-lg border border-solid border-transparent bg-transparent px-2 text-label-sm font-bold text-ds-text-neutral-muted-default transition-colors hover:bg-ds-bg-neutral-subtle-default hover:text-ds-text-neutral-default-default hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] hover:ring-1 hover:ring-ds-border-neutral-default-default focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none data-[state=active]:bg-transparent data-[state=active]:text-ds-text-neutral-default-default data-[state=active]:shadow-none data-[state=active]:ring-0 data-[state=active]:hover:bg-ds-bg-neutral-subtle-default disabled:pointer-events-none disabled:opacity-50 [&_svg]:text-ds-icon-neutral-default-default';
|
||||
|
||||
/** Gap (px) between tab row and underline — matches HistoryTabsNav. */
|
||||
const BORDER_TAB_UNDERLINE_GAP_PX = 8;
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
type TabsListProps = React.ComponentPropsWithoutRef<
|
||||
typeof TabsPrimitive.List
|
||||
> & {
|
||||
variant?: 'default' | 'outline';
|
||||
variant?: TabsVariant;
|
||||
};
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
TabsListProps
|
||||
>(({ className, variant = 'default', ...props }, ref) => {
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const tabsListRef = React.useRef<React.ElementRef<
|
||||
typeof TabsPrimitive.List
|
||||
> | null>(null) as React.MutableRefObject<React.ElementRef<
|
||||
typeof TabsPrimitive.List
|
||||
> | null>;
|
||||
const [sliderStyle, setSliderStyle] = React.useState({ left: 0, width: 0 });
|
||||
const [borderBarStyle, setBorderBarStyle] = React.useState({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
// Update slider position when active tab changes
|
||||
// Update underline position when active tab changes (outline: inside list; border: below row, HistoryTabsNav-style)
|
||||
React.useLayoutEffect(() => {
|
||||
if (variant !== 'outline' || !tabsListRef.current) return;
|
||||
if (
|
||||
!tabsListRef.current ||
|
||||
(variant !== 'outline' && variant !== 'border')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateSlider = () => {
|
||||
// Use requestAnimationFrame to ensure DOM has updated
|
||||
requestAnimationFrame(() => {
|
||||
const activeTab = tabsListRef.current?.querySelector(
|
||||
'[data-state="active"][data-variant="outline"]'
|
||||
) as HTMLElement;
|
||||
const list = tabsListRef.current;
|
||||
const wrap = wrapperRef.current;
|
||||
if (!list) return;
|
||||
|
||||
if (activeTab && tabsListRef.current) {
|
||||
const containerRect = tabsListRef.current.getBoundingClientRect();
|
||||
if (variant === 'outline') {
|
||||
const activeTab = list.querySelector(
|
||||
'[data-state="active"][data-variant="outline"]'
|
||||
) as HTMLElement | null;
|
||||
if (activeTab) {
|
||||
const containerRect = list.getBoundingClientRect();
|
||||
const tabRect = activeTab.getBoundingClientRect();
|
||||
setSliderStyle({
|
||||
left: tabRect.left - containerRect.left,
|
||||
width: tabRect.width,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTab = list.querySelector(
|
||||
'[data-state="active"][data-variant="border"]'
|
||||
) as HTMLElement | null;
|
||||
if (activeTab && wrap) {
|
||||
const wr = wrap.getBoundingClientRect();
|
||||
const tabRect = activeTab.getBoundingClientRect();
|
||||
|
||||
setSliderStyle({
|
||||
left: tabRect.left - containerRect.left,
|
||||
setBorderBarStyle({
|
||||
left: tabRect.left - wr.left,
|
||||
top: tabRect.bottom - wr.top + BORDER_TAB_UNDERLINE_GAP_PX,
|
||||
width: tabRect.width,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Initial update
|
||||
updateSlider();
|
||||
|
||||
// Watch for changes
|
||||
const observer = new MutationObserver(updateSlider);
|
||||
if (tabsListRef.current) {
|
||||
observer.observe(tabsListRef.current, {
|
||||
|
|
@ -82,7 +120,6 @@ const TabsList = React.forwardRef<
|
|||
});
|
||||
}
|
||||
|
||||
// Also listen for resize
|
||||
window.addEventListener('resize', updateSlider);
|
||||
|
||||
return () => {
|
||||
|
|
@ -109,14 +146,20 @@ const TabsList = React.forwardRef<
|
|||
|
||||
return (
|
||||
<TabsContext.Provider value={{ variant }}>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn('relative', variant === 'border' && 'pb-2')}
|
||||
>
|
||||
<TabsPrimitive.List
|
||||
ref={combinedRef}
|
||||
className={cn(
|
||||
'rounded-xl bg-ds-bg-neutral-strong-default p-0.5 inline-flex items-center justify-center border border-solid',
|
||||
variant === 'outline'
|
||||
? 'border-ds-border-neutral-default-default relative'
|
||||
: 'border-[color:var(--ds-bg-neutral-strong-default)]',
|
||||
'inline-flex items-center justify-center',
|
||||
variant === 'border' &&
|
||||
'gap-2 p-0 rounded-none border-0 border-solid bg-transparent shadow-none',
|
||||
variant === 'outline' &&
|
||||
'rounded-xl border-ds-border-neutral-default-default bg-ds-bg-neutral-strong-default p-0.5 relative border border-solid',
|
||||
variant === 'default' &&
|
||||
'rounded-xl bg-ds-bg-neutral-strong-default p-0.5 border border-solid border-[color:var(--ds-bg-neutral-strong-default)]',
|
||||
'data-[orientation=vertical]:flex data-[orientation=vertical]:h-full data-[orientation=vertical]:w-full data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch data-[orientation=vertical]:justify-start',
|
||||
className
|
||||
)}
|
||||
|
|
@ -138,6 +181,24 @@ const TabsList = React.forwardRef<
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{variant === 'border' && borderBarStyle.width > 0 && (
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className="bg-ds-bg-brand-default-default h-0.5 pointer-events-none absolute z-10 rounded-full"
|
||||
initial={false}
|
||||
animate={{
|
||||
left: borderBarStyle.left,
|
||||
top: borderBarStyle.top,
|
||||
width: borderBarStyle.width,
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 420,
|
||||
damping: 34,
|
||||
mass: 0.55,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
|
|
@ -147,7 +208,7 @@ TabsList.displayName = TabsPrimitive.List.displayName;
|
|||
type TabsTriggerProps = React.ComponentPropsWithoutRef<
|
||||
typeof TabsPrimitive.Trigger
|
||||
> & {
|
||||
variant?: 'default' | 'outline';
|
||||
variant?: TabsVariant;
|
||||
};
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
|
|
@ -156,11 +217,13 @@ const TabsTrigger = React.forwardRef<
|
|||
>(({ className, variant: propVariant, ...props }, ref) => {
|
||||
const { variant: contextVariant } = React.useContext(TabsContext);
|
||||
const variant = propVariant || contextVariant || 'default';
|
||||
const triggerBase =
|
||||
variant === 'border' ? tabsTriggerBorderClassName : tabsTriggerClassName;
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(tabsTriggerClassName, className)}
|
||||
className={cn(triggerBase, className)}
|
||||
data-variant={variant}
|
||||
data-value={props.value}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export const checkboxTokenAliases = asCssVarMap({
|
|||
export const switchTokenAliases = asCssVarMap({
|
||||
'--switch-on-fill-track-fill':
|
||||
'var(--ds-bg-status-completed-default-default)',
|
||||
'--switch-off-fill-track-fill': 'var(--ds-bg-neutral-default-default)',
|
||||
'--switch-off-fill-track-fill': 'var(--ds-bg-neutral-subtle-default)',
|
||||
'--switch-on-fill-thumb-fill': 'var(--ds-text-brand-inverse-default)',
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ interface UseIsInViewOptions {
|
|||
function useIsInView<T extends HTMLElement = HTMLElement>(
|
||||
ref: React.Ref<T>,
|
||||
options: UseIsInViewOptions = {}
|
||||
): { ref: React.MutableRefObject<T | null>; isInView: boolean } {
|
||||
) {
|
||||
const { inView, inViewOnce = false, inViewMargin = '0px' } = options;
|
||||
const localRef = React.useRef<T>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as T);
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@
|
|||
"new-project": "مشروع جديد",
|
||||
"no-reply-received-task-continue": "لم يتم استلام رد، تستمر المهمة",
|
||||
"splitting-tasks": "تقسيم المهام",
|
||||
"working-on-tasks-for": "العمل على المهام لمدة {{time}}",
|
||||
"worked-for": "عُمل لمدة {{time}}",
|
||||
"start-task": "بدء المهمة",
|
||||
"message-cannot-be-empty": "لا يمكن أن تكون الرسالة فارغة",
|
||||
"remove-file": "إزالة الملف",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@
|
|||
"new-project": "Neues Projekt",
|
||||
"no-reply-received-task-continue": "Keine Antwort erhalten, Aufgabe wird fortgesetzt",
|
||||
"splitting-tasks": "Aufgaben teilen",
|
||||
"working-on-tasks-for": "Arbeitet an Aufgaben seit {{time}}",
|
||||
"worked-for": "Gearbeitet für {{time}}",
|
||||
"start-task": "Aufgabe starten",
|
||||
"message-cannot-be-empty": "Nachricht darf nicht leer sein",
|
||||
"remove-file": "Datei entfernen",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@
|
|||
"new-project": "Untitled Project",
|
||||
"no-reply-received-task-continue": "No reply received, task continue",
|
||||
"splitting-tasks": "Splitting Tasks",
|
||||
"working-on-tasks-for": "Working on tasks for {{time}}",
|
||||
"worked-for": "Worked for {{time}}",
|
||||
"start-task": "Start Task",
|
||||
"message-cannot-be-empty": "Message cannot be empty",
|
||||
"remove-file": "Remove file",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@
|
|||
"new-project": "Nuevo proyecto",
|
||||
"no-reply-received-task-continue": "No se recibió respuesta, la tarea continúa",
|
||||
"splitting-tasks": "Dividiendo tareas",
|
||||
"working-on-tasks-for": "Trabajando en tareas durante {{time}}",
|
||||
"worked-for": "Trabajó durante {{time}}",
|
||||
"start-task": "Iniciar tarea",
|
||||
"message-cannot-be-empty": "El mensaje no puede estar vacío",
|
||||
"remove-file": "Eliminar archivo",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@
|
|||
"new-project": "Nouveau projet",
|
||||
"no-reply-received-task-continue": "Aucune réponse reçue, la tâche continue",
|
||||
"splitting-tasks": "Division des tâches",
|
||||
"working-on-tasks-for": "Travaille sur les tâches depuis {{time}}",
|
||||
"worked-for": "A travaillé pendant {{time}}",
|
||||
"start-task": "Démarrer la tâche",
|
||||
"message-cannot-be-empty": "Le message ne peut pas être vide",
|
||||
"remove-file": "Supprimer le fichier",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@
|
|||
"new-project": "Nuovo Progetto",
|
||||
"no-reply-received-task-continue": "Nessuna risposta ricevuta, il compito continua",
|
||||
"splitting-tasks": "Suddivisione dei compiti",
|
||||
"working-on-tasks-for": "Lavoro alle attività da {{time}}",
|
||||
"worked-for": "Ha lavorato per {{time}}",
|
||||
"start-task": "Avvia compito",
|
||||
"message-cannot-be-empty": "Il messaggio non può essere vuoto",
|
||||
"remove-file": "Rimuovi file",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@
|
|||
"new-project": "新規プロジェクト",
|
||||
"no-reply-received-task-continue": "応答がないため、タスクを続行します",
|
||||
"splitting-tasks": "タスク分割",
|
||||
"working-on-tasks-for": "タスクを実行中 · {{time}}",
|
||||
"worked-for": "作業時間 {{time}}",
|
||||
"start-task": "タスク開始",
|
||||
"message-cannot-be-empty": "メッセージは空にできません",
|
||||
"remove-file": "ファイルを削除",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@
|
|||
"new-project": "새 프로젝트",
|
||||
"no-reply-received-task-continue": "응답이 없으므로 작업을 계속합니다.",
|
||||
"splitting-tasks": "작업 분할",
|
||||
"working-on-tasks-for": "작업 진행 중 · {{time}}",
|
||||
"worked-for": "작업 시간 {{time}}",
|
||||
"start-task": "작업 시작",
|
||||
"message-cannot-be-empty": "메시지는 비워둘 수 없습니다",
|
||||
"remove-file": "파일 제거",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@
|
|||
"new-project": "Новый проект",
|
||||
"no-reply-received-task-continue": "Ответ не получен, задача продолжается",
|
||||
"splitting-tasks": "Разделение задач",
|
||||
"working-on-tasks-for": "Работа над задачами · {{time}}",
|
||||
"worked-for": "Работал {{time}}",
|
||||
"start-task": "Начать задачу",
|
||||
"message-cannot-be-empty": "Сообщение не может быть пустым",
|
||||
"remove-file": "Удалить файл",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@
|
|||
"new-project": "新项目",
|
||||
"no-reply-received-task-continue": "没有收到回复,任务继续",
|
||||
"splitting-tasks": "拆分任务",
|
||||
"working-on-tasks-for": "正在处理任务 · {{time}}",
|
||||
"worked-for": "已工作 {{time}}",
|
||||
"start-task": "开始任务",
|
||||
"message-cannot-be-empty": "消息不能为空",
|
||||
"remove-file": "移除文件",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@
|
|||
"new-project": "新項目",
|
||||
"no-reply-received-task-continue": "沒有收到回复,任務繼續",
|
||||
"splitting-tasks": "拆分任務",
|
||||
"working-on-tasks-for": "正在處理任務 · {{time}}",
|
||||
"worked-for": "已工作 {{time}}",
|
||||
"start-task": "開始任務",
|
||||
"message-cannot-be-empty": "訊息不能為空",
|
||||
"remove-file": "移除文件",
|
||||
|
|
|
|||
|
|
@ -160,7 +160,8 @@ type FixedShade =
|
|||
| '900'
|
||||
| '950';
|
||||
|
||||
const SYSTEM_STATUS_SHADE_BY_EMPHASIS: Record<
|
||||
/** Light: pale surfaces → saturated accents (50 / 300 / 600 / 900). */
|
||||
const SYSTEM_STATUS_SHADE_BY_EMPHASIS_LIGHT: Record<
|
||||
Extract<Emphasis, 'subtle' | 'muted' | 'default' | 'strong'>,
|
||||
Record<State, FixedShade>
|
||||
> = {
|
||||
|
|
@ -198,6 +199,49 @@ const SYSTEM_STATUS_SHADE_BY_EMPHASIS: Record<
|
|||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Dark: reverse the ramp so “subtle” reads as a deep tint and “strong” as a
|
||||
* light accent — subtle→950, muted→600, default→300, strong→50 (mirrored
|
||||
* hover/active steps vs. light).
|
||||
*/
|
||||
const SYSTEM_STATUS_SHADE_BY_EMPHASIS_DARK: Record<
|
||||
Extract<Emphasis, 'subtle' | 'muted' | 'default' | 'strong'>,
|
||||
Record<State, FixedShade>
|
||||
> = {
|
||||
subtle: {
|
||||
default: '950',
|
||||
hover: '900',
|
||||
active: '800',
|
||||
selected: '800',
|
||||
focus: '900',
|
||||
disabled: '950',
|
||||
},
|
||||
muted: {
|
||||
default: '600',
|
||||
hover: '500',
|
||||
active: '400',
|
||||
selected: '400',
|
||||
focus: '500',
|
||||
disabled: '600',
|
||||
},
|
||||
default: {
|
||||
default: '300',
|
||||
hover: '400',
|
||||
active: '500',
|
||||
selected: '500',
|
||||
focus: '400',
|
||||
disabled: '300',
|
||||
},
|
||||
strong: {
|
||||
default: '50',
|
||||
hover: '100',
|
||||
active: '100',
|
||||
selected: '100',
|
||||
focus: '100',
|
||||
disabled: '50',
|
||||
},
|
||||
};
|
||||
|
||||
// Per-state opacity used for the `transparent` emphasis. The surface color is
|
||||
// the tone's base hue shown at these alphas so status chips read as "main
|
||||
// color, faded" rather than a washed-out rendered hue.
|
||||
|
|
@ -256,9 +300,14 @@ function getFixedShade(tone: Tone, shade: FixedShade): `#${string}` | null {
|
|||
function getSystemStatusShade(
|
||||
tone: Tone,
|
||||
emphasis: Extract<Emphasis, 'subtle' | 'muted' | 'default' | 'strong'>,
|
||||
state: State
|
||||
state: State,
|
||||
mode: Mode
|
||||
): `#${string}` | null {
|
||||
const shade = SYSTEM_STATUS_SHADE_BY_EMPHASIS[emphasis][state];
|
||||
const table =
|
||||
mode === 'dark'
|
||||
? SYSTEM_STATUS_SHADE_BY_EMPHASIS_DARK
|
||||
: SYSTEM_STATUS_SHADE_BY_EMPHASIS_LIGHT;
|
||||
const shade = table[emphasis][state];
|
||||
return getFixedShade(tone, shade);
|
||||
}
|
||||
|
||||
|
|
@ -741,8 +790,10 @@ function buildSemanticTokens(
|
|||
|
||||
if (SYSTEM_STATUS_TONES.has(tone) && emph !== 'inverse') {
|
||||
if (emph === 'transparent') {
|
||||
const transparentShade: FixedShade =
|
||||
contract.mode === 'dark' ? '300' : '600';
|
||||
const baseHex =
|
||||
getFixedShade(tone, '600') ??
|
||||
getFixedShade(tone, transparentShade) ??
|
||||
oklchToHex(toneBaseColor(tone, contract.mode, seed, element));
|
||||
const stateAlpha =
|
||||
SYSTEM_STATUS_TRANSPARENT_OPACITY_BY_STATE[state as State] ??
|
||||
|
|
@ -761,7 +812,8 @@ function buildSemanticTokens(
|
|||
Emphasis,
|
||||
'subtle' | 'muted' | 'default' | 'strong'
|
||||
>,
|
||||
state
|
||||
state,
|
||||
contract.mode
|
||||
);
|
||||
if (statusShade) {
|
||||
tokens[tokenKey] =
|
||||
|
|
|
|||
|
|
@ -102,23 +102,17 @@ export default function Skills() {
|
|||
<Tabs defaultValue="your-skills" className="w-full">
|
||||
<div className="gap-4 border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default sticky top-[84px] z-10 flex w-full items-center justify-between border-x-0 border-t-0 border-b-[0.5px] border-solid">
|
||||
<TabsList
|
||||
variant="outline"
|
||||
className="h-auto flex-1 justify-start border-0 bg-transparent"
|
||||
variant="border"
|
||||
className="h-auto flex-1 justify-start"
|
||||
>
|
||||
<TabsTrigger
|
||||
value="your-skills"
|
||||
className="data-[state=active]:bg-transparent"
|
||||
>
|
||||
<TabsTrigger value="your-skills">
|
||||
{t('agents.your-skills')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="example-skills"
|
||||
className="data-[state=active]:bg-transparent"
|
||||
>
|
||||
<TabsTrigger value="example-skills">
|
||||
{t('agents.example-skills')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="gap-2 flex items-center">
|
||||
<div className="gap-2 mb-2 flex items-center">
|
||||
<SearchInput
|
||||
variant="icon"
|
||||
value={searchQuery}
|
||||
|
|
|
|||
|
|
@ -573,23 +573,17 @@ export default function SettingMCP() {
|
|||
>
|
||||
<div className="gap-4 border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default sticky top-[84px] z-10 flex w-full items-center justify-between border-x-0 border-t-0 border-b-[0.5px] border-solid">
|
||||
<TabsList
|
||||
variant="outline"
|
||||
className="h-auto flex-1 justify-start border-0 bg-transparent"
|
||||
variant="border"
|
||||
className="h-auto flex-1 justify-start"
|
||||
>
|
||||
<TabsTrigger
|
||||
value="mcp-tools"
|
||||
className="data-[state=active]:bg-transparent"
|
||||
>
|
||||
<TabsTrigger value="mcp-tools">
|
||||
{t('setting.mcp-and-tools')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="your-mcp"
|
||||
className="data-[state=active]:bg-transparent"
|
||||
>
|
||||
<TabsTrigger value="your-mcp">
|
||||
{t('setting.your-own-mcps')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="gap-2 flex items-center">
|
||||
<div className="gap-2 mb-2 flex items-center">
|
||||
<SearchInput
|
||||
variant="icon"
|
||||
value={searchQuery}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ export default function Home() {
|
|||
const shellPanelGroupImperativeRef = useRef<ImperativePanelGroupHandle>(null);
|
||||
const projectSidebarPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
const applyingSidebarLayoutRef = useRef(false);
|
||||
const sidebarLayoutAnimationFrameRef = useRef<number | null>(null);
|
||||
const hasInitializedSidebarLayoutRef = useRef(false);
|
||||
/** Expanded sidebar width in px; only user drag (or stored value) changes this — window resize adjusts % to keep this width. */
|
||||
const sidebarWidthPxRef = useRef(readStoredSidebarWidthPx());
|
||||
const persistSidebarWidthTimeoutRef = useRef<ReturnType<
|
||||
|
|
@ -171,28 +173,83 @@ export default function Home() {
|
|||
}, 250);
|
||||
}, []);
|
||||
|
||||
const setShellPanelLayout = useCallback(
|
||||
(layout: number[], animate: boolean) => {
|
||||
const group = shellPanelGroupImperativeRef.current;
|
||||
if (!group) return;
|
||||
|
||||
const target = layout.map(clampPct);
|
||||
|
||||
if (sidebarLayoutAnimationFrameRef.current != null) {
|
||||
cancelAnimationFrame(sidebarLayoutAnimationFrameRef.current);
|
||||
sidebarLayoutAnimationFrameRef.current = null;
|
||||
}
|
||||
|
||||
const applyFinalLayout = () => {
|
||||
group.setLayout(target);
|
||||
requestAnimationFrame(() => {
|
||||
applyingSidebarLayoutRef.current = false;
|
||||
});
|
||||
};
|
||||
|
||||
const current = group.getLayout();
|
||||
const shouldAnimate =
|
||||
animate &&
|
||||
current.length === target.length &&
|
||||
current.some((value, index) => Math.abs(value - target[index]) > 0.1);
|
||||
|
||||
applyingSidebarLayoutRef.current = true;
|
||||
|
||||
if (!shouldAnimate) {
|
||||
applyFinalLayout();
|
||||
return;
|
||||
}
|
||||
|
||||
const from = [...current];
|
||||
const durationMs = 260;
|
||||
const start = performance.now();
|
||||
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min(1, (now - start) / durationMs);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
group.setLayout(
|
||||
from.map((value, index) => value + (target[index] - value) * eased)
|
||||
);
|
||||
|
||||
if (progress < 1) {
|
||||
sidebarLayoutAnimationFrameRef.current = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
sidebarLayoutAnimationFrameRef.current = null;
|
||||
applyFinalLayout();
|
||||
};
|
||||
|
||||
sidebarLayoutAnimationFrameRef.current = requestAnimationFrame(tick);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/** Recompute sidebar % from fixed px so the rail does not grow/shrink when the window resizes. */
|
||||
const applyExpandedSidebarLayout = useCallback(() => {
|
||||
const shell = shellPanelGroupRef.current;
|
||||
const group = shellPanelGroupImperativeRef.current;
|
||||
if (!shell || !group) return;
|
||||
if (usePageTabStore.getState().projectSidebarFolded) return;
|
||||
const w = shell.getBoundingClientRect().width;
|
||||
if (w <= 0) return;
|
||||
const minPct = clampPct((SIDEBAR_MIN_PX / w) * 100);
|
||||
const maxPct = clampPct((SIDEBAR_MAX_PX / w) * 100);
|
||||
const px = Math.min(
|
||||
SIDEBAR_MAX_PX,
|
||||
Math.max(SIDEBAR_MIN_PX, sidebarWidthPxRef.current)
|
||||
);
|
||||
let pct = (px / w) * 100;
|
||||
pct = Math.min(maxPct, Math.max(minPct, pct));
|
||||
applyingSidebarLayoutRef.current = true;
|
||||
group.setLayout([pct, 100 - pct]);
|
||||
requestAnimationFrame(() => {
|
||||
applyingSidebarLayoutRef.current = false;
|
||||
});
|
||||
}, []);
|
||||
const applyExpandedSidebarLayout = useCallback(
|
||||
(animate: boolean = false) => {
|
||||
const shell = shellPanelGroupRef.current;
|
||||
if (!shell) return;
|
||||
if (usePageTabStore.getState().projectSidebarFolded) return;
|
||||
const w = shell.getBoundingClientRect().width;
|
||||
if (w <= 0) return;
|
||||
const minPct = clampPct((SIDEBAR_MIN_PX / w) * 100);
|
||||
const maxPct = clampPct((SIDEBAR_MAX_PX / w) * 100);
|
||||
const px = Math.min(
|
||||
SIDEBAR_MAX_PX,
|
||||
Math.max(SIDEBAR_MIN_PX, sidebarWidthPxRef.current)
|
||||
);
|
||||
let pct = (px / w) * 100;
|
||||
pct = Math.min(maxPct, Math.max(minPct, pct));
|
||||
setShellPanelLayout([pct, 100 - pct], animate);
|
||||
},
|
||||
[setShellPanelLayout]
|
||||
);
|
||||
|
||||
const handleShellPanelLayout = useCallback(
|
||||
(sizes: number[]) => {
|
||||
|
|
@ -240,26 +297,21 @@ export default function Home() {
|
|||
/** Expanded: apply stored px width when leaving folded or on first paint. */
|
||||
useLayoutEffect(() => {
|
||||
if (projectSidebarFolded) return;
|
||||
applyExpandedSidebarLayout();
|
||||
applyExpandedSidebarLayout(hasInitializedSidebarLayoutRef.current);
|
||||
hasInitializedSidebarLayoutRef.current = true;
|
||||
}, [projectSidebarFolded, applyExpandedSidebarLayout]);
|
||||
|
||||
/** Folded: exact rail + main split (`setLayout`); update when shell width changes rail %. */
|
||||
useLayoutEffect(() => {
|
||||
if (!projectSidebarFolded) return;
|
||||
const shell = shellPanelGroupRef.current;
|
||||
const group = shellPanelGroupImperativeRef.current;
|
||||
if (!shell || !group) return;
|
||||
const w = shell.getBoundingClientRect().width;
|
||||
if (w <= 0) return;
|
||||
|
||||
applyingSidebarLayoutRef.current = true;
|
||||
const rail = sidebarPct.rail;
|
||||
const main = Math.min(99, Math.max(0, 100 - rail));
|
||||
group.setLayout([rail, main]);
|
||||
requestAnimationFrame(() => {
|
||||
applyingSidebarLayoutRef.current = false;
|
||||
});
|
||||
}, [projectSidebarFolded, sidebarPct.rail]);
|
||||
setShellPanelLayout(
|
||||
[rail, main],
|
||||
hasInitializedSidebarLayoutRef.current && sidebarWidthPxRef.current > 0
|
||||
);
|
||||
hasInitializedSidebarLayoutRef.current = true;
|
||||
}, [projectSidebarFolded, sidebarPct.rail, setShellPanelLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = shellPanelGroupRef.current;
|
||||
|
|
@ -296,6 +348,9 @@ export default function Home() {
|
|||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (sidebarLayoutAnimationFrameRef.current != null) {
|
||||
cancelAnimationFrame(sidebarLayoutAnimationFrameRef.current);
|
||||
}
|
||||
if (persistSidebarWidthTimeoutRef.current) {
|
||||
clearTimeout(persistSidebarWidthTimeoutRef.current);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
"information": "#2563eb",
|
||||
"status-running": "#2563eb",
|
||||
"status-splitting": "#0284c7",
|
||||
"status-pending": "#d97706",
|
||||
"status-pending": "#4a69af",
|
||||
"status-error": "#dc2626",
|
||||
"status-reassigning": "#ea580c",
|
||||
"status-completed": "#16a34a",
|
||||
|
|
@ -82,7 +82,7 @@
|
|||
"information": "#2563eb",
|
||||
"status-running": "#2563eb",
|
||||
"status-splitting": "#0284c7",
|
||||
"status-pending": "#d97706",
|
||||
"status-pending": "#4a69af",
|
||||
"status-error": "#dc2626",
|
||||
"status-reassigning": "#ea580c",
|
||||
"status-completed": "#16a34a",
|
||||
|
|
@ -172,17 +172,17 @@
|
|||
"950": "#082f49"
|
||||
},
|
||||
"status-pending": {
|
||||
"50": "#fffbeb",
|
||||
"100": "#fef3c7",
|
||||
"200": "#fde68a",
|
||||
"300": "#fcd34d",
|
||||
"400": "#fbbf24",
|
||||
"500": "#f59e0b",
|
||||
"600": "#d97706",
|
||||
"700": "#b45309",
|
||||
"800": "#92400e",
|
||||
"900": "#78350f",
|
||||
"950": "#451a03"
|
||||
"50": "#e0e8f6",
|
||||
"100": "#d0dcf1",
|
||||
"200": "#b3c5e8",
|
||||
"300": "#92abdc",
|
||||
"400": "#7291cf",
|
||||
"500": "#5778c0",
|
||||
"600": "#4a69af",
|
||||
"700": "#3f598f",
|
||||
"800": "#374d79",
|
||||
"900": "#314163",
|
||||
"950": "#222d45"
|
||||
},
|
||||
"status-error": {
|
||||
"50": "#fef2f2",
|
||||
|
|
|
|||
|
|
@ -314,24 +314,46 @@ describe('themeTokens v2 engine', () => {
|
|||
expect(successInverse).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('maps system status background emphases to 50/300/600/900 shades', () => {
|
||||
for (const mode of ['light', 'dark'] as const) {
|
||||
const theme = buildThemeV2(
|
||||
createDefaultThemeContractV2(mode, {
|
||||
themeId: 'eigent',
|
||||
contrast: 50,
|
||||
}),
|
||||
DEFAULT_THEME_CATALOG
|
||||
it('maps system status background emphases to fixed shade steps (light vs dark)', () => {
|
||||
const lightTheme = buildThemeV2(
|
||||
createDefaultThemeContractV2('light', {
|
||||
themeId: 'eigent',
|
||||
contrast: 50,
|
||||
}),
|
||||
DEFAULT_THEME_CATALOG
|
||||
);
|
||||
const darkTheme = buildThemeV2(
|
||||
createDefaultThemeContractV2('dark', {
|
||||
themeId: 'eigent',
|
||||
contrast: 50,
|
||||
}),
|
||||
DEFAULT_THEME_CATALOG
|
||||
);
|
||||
|
||||
for (const tone of SYSTEM_STATUS_TONES) {
|
||||
const scale = FIXED_SHADE_SCALES[tone];
|
||||
expect(scale).toBeDefined();
|
||||
expect(lightTheme.tokens[`bg.${tone}.subtle.default`]).toBe(
|
||||
scale?.['50']
|
||||
);
|
||||
expect(lightTheme.tokens[`bg.${tone}.muted.default`]).toBe(
|
||||
scale?.['300']
|
||||
);
|
||||
expect(lightTheme.tokens[`bg.${tone}.default.default`]).toBe(
|
||||
scale?.['600']
|
||||
);
|
||||
expect(lightTheme.tokens[`bg.${tone}.strong.default`]).toBe(
|
||||
scale?.['900']
|
||||
);
|
||||
|
||||
for (const tone of SYSTEM_STATUS_TONES) {
|
||||
const scale = FIXED_SHADE_SCALES[tone];
|
||||
expect(scale).toBeDefined();
|
||||
expect(theme.tokens[`bg.${tone}.subtle.default`]).toBe(scale?.['50']);
|
||||
expect(theme.tokens[`bg.${tone}.muted.default`]).toBe(scale?.['300']);
|
||||
expect(theme.tokens[`bg.${tone}.default.default`]).toBe(scale?.['600']);
|
||||
expect(theme.tokens[`bg.${tone}.strong.default`]).toBe(scale?.['900']);
|
||||
}
|
||||
expect(darkTheme.tokens[`bg.${tone}.subtle.default`]).toBe(
|
||||
scale?.['950']
|
||||
);
|
||||
expect(darkTheme.tokens[`bg.${tone}.muted.default`]).toBe(scale?.['600']);
|
||||
expect(darkTheme.tokens[`bg.${tone}.default.default`]).toBe(
|
||||
scale?.['300']
|
||||
);
|
||||
expect(darkTheme.tokens[`bg.${tone}.strong.default`]).toBe(scale?.['50']);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue