- {popoverButton}
- {/* 🌟 Logo */}
-
-
-
Takumi
-
-
- {/* 🌟 添加会话 */}
+
+
{
- console.log('add conversation');
- }}
- type="link"
- className={styles.addBtn}
icon={ }
+ type="text"
+ className={styles.addBtn}
+ onClick={newSession}
>
- {t('sidebar.newConversation')}
+ {t('project.new')}
-
- {/* 🌟 会话管理 */}
-
{
- console.log('onActiveChange', val);
- }}
- groupable
- styles={{ item: { padding: '0 8px' } }}
- menu={(conversation) => ({
- items: [
- {
- label: t('menu.rename'),
- key: 'rename',
- icon: ,
- },
- {
- label: t('menu.delete'),
- key: 'delete',
- icon: ,
- danger: true,
- onClick: () => {
- console.log('delete conversation', conversation);
- },
- },
- ],
- })}
- />
-
-
-
+
+
+
+
+ }
+ onClick={() => uiActions.openProjectSelectModal()}
+ >
+ {t('project.projectManagement')}
+
+
}
- onClick={() => navigate({ to: '/settings' })}
+ onClick={() => uiActions.openSettingsModal()}
/>
} />
diff --git a/browser/src/components/Sider/imgs/sider-bg.png b/browser/src/components/Sider/imgs/sider-bg.png
new file mode 100644
index 000000000..22270e97d
Binary files /dev/null and b/browser/src/components/Sider/imgs/sider-bg.png differ
diff --git a/browser/src/components/Sider/index.tsx b/browser/src/components/Sider/index.tsx
index 33078e5ec..b7839a7b3 100644
--- a/browser/src/components/Sider/index.tsx
+++ b/browser/src/components/Sider/index.tsx
@@ -4,6 +4,7 @@ import { createStyles } from 'antd-style';
import { useEffect, useState } from 'react';
import { useSnapshot } from 'valtio';
import * as codeViewer from '@/state/codeViewer';
+import * as layout from '@/state/layout';
import SiderMain from './SiderMain';
const useStyles = createStyles(({ css, cx, token }) => {
@@ -51,13 +52,51 @@ const useStyles = createStyles(({ css, cx, token }) => {
display: block;
margin: 0 0 20px 0;
`,
+ // Floating mode styles
+ floatingWrapper: css`
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 1001;
+ height: 100vh;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ `,
+ floatingContent: css`
+ width: 280px;
+ height: 100vh;
+ background: ${token.colorBgContainer};
+ box-shadow: 4px 4px 30px 0px rgba(184, 184, 184, 0.25);
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ transform: translateX(0);
+ `,
+ // Hidden state - use transform instead of display:none
+ hidden: css`
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 1001;
+ height: 100vh;
+ transform: translateX(-100%);
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ visibility: hidden;
+ opacity: 0;
+ `,
+ hiddenContent: css`
+ width: 280px;
+ height: 100vh;
+ background: ${token.colorBgContainer};
+ box-shadow: 4px 4px 30px 0px rgba(184, 184, 184, 0.25);
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ `,
};
});
const Sider = () => {
const { visible: codeViewerVisible } = useSnapshot(codeViewer.state);
+ const { sidebarCollapsed, rightPanelExpanded } = useSnapshot(layout.state);
const [active, setActive] = useState(false);
const { styles } = useStyles();
+ const isHidden = sidebarCollapsed;
const MenuButton = (
{
setActive(false);
}, [codeViewerVisible]);
- return codeViewerVisible ? (
- <>
- {active ? (
+ // Use floating mode when right panel is expanded (includes both hidden and visible states)
+ if (rightPanelExpanded) {
+ return (
+
setActive(false)}
+ className={isHidden ? styles.hiddenContent : styles.floatingContent}
>
-
- {MenuButton}
- }
- />
-
+
+
+
+ );
+ }
+
+ // Keep original codeViewer logic unchanged
+ return codeViewerVisible ? (
+ active ? (
+
setActive(false)}
+ >
+
+
- ) : (
-
{MenuButton}
- )}
- >
+
+ ) : (
+
{MenuButton}
+ )
) : (
);
diff --git a/browser/src/components/SuggestionList/AutoTooltip.tsx b/browser/src/components/SuggestionList/AutoTooltip.tsx
deleted file mode 100644
index 0bdf5c2fa..000000000
--- a/browser/src/components/SuggestionList/AutoTooltip.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { Tooltip } from 'antd';
-import React, { useLayoutEffect, useRef, useState } from 'react';
-
-interface Props
- extends React.DetailedHTMLProps<
- React.HTMLAttributes
,
- HTMLDivElement
- > {
- maxWidth?: number;
-}
-
-const AutoTooltip = (props: Props) => {
- const ref = useRef(null);
- const [showTip, setShowTip] = useState(false);
-
- useLayoutEffect(() => {
- if (ref.current) {
- if (ref.current.scrollWidth > ref.current.clientWidth) {
- setShowTip(true);
- } else {
- setShowTip(false);
- }
- }
- }, []);
-
- return (
-
-
-
- );
-};
-
-export default AutoTooltip;
diff --git a/browser/src/components/SuggestionList/ListFooter.tsx b/browser/src/components/SuggestionList/ListFooter.tsx
new file mode 100644
index 000000000..b933a0a59
--- /dev/null
+++ b/browser/src/components/SuggestionList/ListFooter.tsx
@@ -0,0 +1,32 @@
+import { useTranslation } from 'react-i18next';
+
+interface IProps {
+ selectedFirstKey?: string;
+}
+
+const ListFooter = ({ selectedFirstKey }: IProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
↑↓
+
{t('suggestion.navigate')}
+
+
+
Enter
+
{t('suggestion.select')}
+
+
+
Esc
+
+ {selectedFirstKey ? t('suggestion.back') : t('suggestion.close')}
+
+
+
+
+ );
+};
+
+export default ListFooter;
diff --git a/browser/src/components/SuggestionList/SmartText.tsx b/browser/src/components/SuggestionList/SmartText.tsx
new file mode 100644
index 000000000..463dde3f4
--- /dev/null
+++ b/browser/src/components/SuggestionList/SmartText.tsx
@@ -0,0 +1,137 @@
+import { type GetProps, Tooltip } from 'antd';
+import { cx } from 'antd-style';
+import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
+
+interface Props {
+ label: React.ReactNode;
+ extra?: React.ReactNode;
+ maxWidth?: number;
+ gap?: number;
+ minExtraWidth?: number;
+ showTip?: boolean;
+ placement?: GetProps['placement'];
+ renderTooltip?: (
+ label: React.ReactNode,
+ extra?: React.ReactNode,
+ ) => React.ReactNode;
+}
+
+type DisplayMode = 'full' | 'extra-truncated' | 'label-truncated';
+
+const SmartText = (props: Props) => {
+ const {
+ label,
+ extra,
+ maxWidth = 280,
+ gap = 4,
+ minExtraWidth = 40,
+ showTip,
+ placement = 'top',
+ renderTooltip,
+ } = props;
+
+ const containerRef = useRef(null);
+ const labelRef = useRef(null);
+ const extraRef = useRef(null);
+
+ const [displayMode, setDisplayMode] = useState('full');
+
+ const hasTip = useMemo(() => displayMode !== 'full', [displayMode]);
+
+ const showExtra = useMemo(
+ () => displayMode !== 'label-truncated',
+ [displayMode],
+ );
+
+ useLayoutEffect(() => {
+ if (!containerRef.current || !labelRef.current) return;
+
+ const labelEl = labelRef.current;
+ const extraEl = extraRef.current;
+
+ const labelWidth = labelEl.scrollWidth;
+ const extraWidth = extraEl ? extraEl.scrollWidth : 0;
+ const totalNaturalWidth = labelWidth + (extraEl ? extraWidth + gap : 0);
+
+ if (totalNaturalWidth <= maxWidth) {
+ // Everything fits, no truncation needed
+ setDisplayMode('full');
+ } else if (!extra) {
+ // Only label, truncate if necessary
+ if (labelWidth > maxWidth) {
+ setDisplayMode('label-truncated');
+ }
+ } else {
+ // Both label and extra exist
+ // Priority: show label completely first, then extra if space allows
+ if (labelWidth + gap + extraWidth <= maxWidth) {
+ // Both fit perfectly
+ setDisplayMode('full');
+ } else if (labelWidth >= maxWidth) {
+ // Label itself needs more space than available, hide extra and truncate label
+ setDisplayMode('label-truncated');
+ } else {
+ // Label fits, but not enough space for both
+ const remainingWidth = maxWidth - labelWidth - gap;
+
+ if (extraWidth <= remainingWidth) {
+ // Extra fits in remaining space
+ setDisplayMode('full');
+ } else {
+ // Try to show truncated extra
+
+ if (remainingWidth >= minExtraWidth) {
+ // Show truncated extra
+ setDisplayMode('extra-truncated');
+ } else {
+ // Not enough space for meaningful extra, hide it
+ setDisplayMode('label-truncated');
+ }
+ }
+ }
+ }
+ }, [label, extra, maxWidth, gap, minExtraWidth]);
+
+ return (
+
+
+
+ {label}
+
+ {showExtra && extra && (
+ <>
+
+
+ {extra}
+
+ >
+ )}
+
+
+ );
+};
+
+export default SmartText;
diff --git a/browser/src/components/SuggestionList/TooltipRender/FileTooltipRender.tsx b/browser/src/components/SuggestionList/TooltipRender/FileTooltipRender.tsx
new file mode 100644
index 000000000..3b4be732c
--- /dev/null
+++ b/browser/src/components/SuggestionList/TooltipRender/FileTooltipRender.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+
+interface Props {
+ fullPath: React.ReactNode;
+ icon: React.ReactNode;
+}
+
+const FileTooltipRender = (props: Props) => {
+ const { fullPath, icon } = props;
+ return (
+
+ );
+};
+
+export default FileTooltipRender;
diff --git a/browser/src/components/SuggestionList/TooltipRender/SlashCommandTooltipRender.tsx b/browser/src/components/SuggestionList/TooltipRender/SlashCommandTooltipRender.tsx
new file mode 100644
index 000000000..c45aa1389
--- /dev/null
+++ b/browser/src/components/SuggestionList/TooltipRender/SlashCommandTooltipRender.tsx
@@ -0,0 +1,14 @@
+interface Props {
+ description: React.ReactNode;
+}
+
+const SlashCommandTooltipRender = (props: Props) => {
+ const { description } = props;
+ return (
+
+ );
+};
+
+export default SlashCommandTooltipRender;
diff --git a/browser/src/components/SuggestionList/TooltipRender/index.tsx b/browser/src/components/SuggestionList/TooltipRender/index.tsx
new file mode 100644
index 000000000..9793fb130
--- /dev/null
+++ b/browser/src/components/SuggestionList/TooltipRender/index.tsx
@@ -0,0 +1,7 @@
+import FileTooltipRender from './FileTooltipRender';
+import SlashCommandTooltipRender from './SlashCommandTooltipRender';
+
+export default {
+ File: FileTooltipRender,
+ SlashCommand: SlashCommandTooltipRender,
+};
diff --git a/browser/src/components/SuggestionList/index.tsx b/browser/src/components/SuggestionList/index.tsx
index d26c7fa71..4b3f5a2f5 100644
--- a/browser/src/components/SuggestionList/index.tsx
+++ b/browser/src/components/SuggestionList/index.tsx
@@ -1,270 +1,526 @@
-import { LeftOutlined } from '@ant-design/icons';
-import { Button, Input, type InputRef, List, Popover } from 'antd';
-import { createStyles } from 'antd-style';
-import React, { useEffect, useMemo, useRef, useState } from 'react';
+import Icon, {
+ ArrowRightOutlined,
+ CheckOutlined,
+ LeftOutlined,
+} from '@ant-design/icons';
+import { Input, type InputRef, List, Popover } from 'antd';
+import { cx } from 'antd-style';
+import { groupBy, throttle } from 'lodash-es';
+import {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import { useTranslation } from 'react-i18next';
-import AutoTooltip from './AutoTooltip';
+import { useSnapshot } from 'valtio';
+import { ContextType } from '@/constants/context';
+import * as context from '@/state/context';
+import type { ContextItem } from '@/types/context';
+import ListFooter from './ListFooter';
+import SmartText from './SmartText';
+import TooltipRender from './TooltipRender';
export type SuggestionItem = {
label: React.ReactNode;
value: string;
-
icon?: React.ReactNode;
-
children?: SuggestionItem[];
-
extra?: React.ReactNode;
+ contextItem?: ContextItem;
+ disabled?: boolean;
};
-interface Props {
+interface SearchControlConfig {
+ searchText?: string;
+ onSearchStart?: () => void;
+}
+
+interface ISuggestionListProps {
className?: string;
children?: React.ReactElement;
open?: boolean;
onOpenChange?: (open: boolean) => void;
items: SuggestionItem[];
- virtual?: boolean;
- onSelect?: (firstKey: string, itemValue: string) => void;
- /** 返回值会覆盖默认的二级列表 */
- onSearch?: (firstKey: string, text: string) => SuggestionItem[] | void;
+ onSelect?: (
+ firstKey: string,
+ itemValue: string,
+ contextItem?: ContextItem,
+ ) => void;
+ onSearch?: (firstKey: string, text: string) => void;
+ onLostFocus?: () => void;
loading?: boolean;
+ offset?: { left: number; top: number };
+ /** if not undefined, will hide the input inside the popup */
+ searchControl?: SearchControlConfig;
+}
+
+export interface ISuggestionListRef {
+ triggerKeyDown: (event: React.KeyboardEvent) => void;
}
-const useStyles = createStyles(({ css, token }) => {
- return {
- listItem: css`
- min-width: 200px;
- height: 40px;
- user-select: none;
- cursor: pointer;
+const renderItemText = (text: React.ReactNode, searchText?: string | null) => {
+ if (!searchText || typeof text !== 'string') {
+ return text;
+ } else {
+ const searchRegex = new RegExp(
+ `(${searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`,
+ 'gi',
+ );
+ const parts = text.split(searchRegex);
+
+ return parts
+ .map((part) => {
+ if (part.toLowerCase() === searchText.toLowerCase()) {
+ return (
+
+ {part}
+
+ );
+ }
+ return part;
+ })
+ .filter((part) => part !== '');
+ }
+};
+
+const SuggestionList = forwardRef(
+ (props, ref) => {
+ const {
+ children,
+ onSearch,
+ onOpenChange,
+ onSelect,
+ open,
+ items,
+ className,
+ loading,
+ offset,
+ onLostFocus,
+ searchControl,
+ } = props;
- &:hover {
- background-color: ${token.controlItemBgHover};
+ const { t } = useTranslation();
+
+ const [selectedFirstKey, setSelectedFirstKey] = useState();
+ const [searchResults, setSearchResults] = useState();
+ const [listPointerEvents, setListPointerEvents] =
+ useState('auto');
+ const [selectedIndex, setSelectedIndex] = useState(-1);
+ const inputRef = useRef(null);
+ const popupRef = useRef(null);
+ const listRef = useRef(null);
+
+ const searchText =
+ inputRef.current?.input?.value ?? searchControl?.searchText;
+
+ const firstLevelList = useMemo(() => items, [items]);
+
+ const secondLevelList = useMemo(() => {
+ if (searchResults) {
+ return searchResults;
+ } else {
+ return (
+ items.find((item) => item.value === selectedFirstKey)?.children || []
+ );
}
- `,
- listItemLabel: css`
- font-weight: 600;
- `,
- listItemLabelSearch: css`
- color: #ff0000;
- `,
- listItemContent: css`
- padding: 0 ${token.paddingSM}px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 100%;
- column-gap: 12px;
- `,
- listItemContentMain: css`
- display: flex;
- align-items: center;
- column-gap: 12px;
- `,
- listHeader: css`
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 4px 0;
- `,
- listInput: css`
- margin: 0 4px;
- `,
- list: css`
- max-height: 500px;
- overflow-y: auto;
- width: 400px;
- `,
- popup: css`
- background-color: ${token.colorBgElevated};
- border-radius: ${token.borderRadius}px;
- border: 1px solid ${token.colorBorder};
- padding: 4px;
- width: fit-content;
- `,
- };
-});
-
-const SuggestionList = (props: Props) => {
- const {
- children,
- onSearch,
- onOpenChange,
- onSelect,
- open,
- items,
- className,
- loading,
- } = props;
-
- const { t } = useTranslation();
- const { styles } = useStyles();
-
- const [selectedFirstKey, setSelectedFirstKey] = useState();
- const [searchResults, setSearchResults] = useState();
- const inputRef = useRef(null);
- const popupRef = useRef(null);
-
- const firstLevelList = useMemo(() => items, [items]);
-
- const secondLevelList = useMemo(() => {
- if (searchResults) {
- return searchResults;
- } else {
- return (
- items.find((item) => item.value === selectedFirstKey)?.children || []
- );
- }
- }, [items, searchResults, selectedFirstKey]);
-
- const clearSearch = (targetFirstKey: string) => {
- if (inputRef.current?.input) {
- inputRef.current.input.value = '';
- }
- setSearchResults(undefined);
- onSearch?.(targetFirstKey, '');
- };
-
- const renderItemText = (
- text: React.ReactNode,
- searchText?: string | null,
- ) => {
- if (!searchText || typeof text !== 'string') {
- return text;
- } else {
- const normalTexts = text.split(searchText);
- const renderedTexts = [
- normalTexts[0],
- ...normalTexts.slice(1).map((text, index) => (
-
- {searchText}
- {text}
-
- )),
- ];
- return renderedTexts;
- }
- };
-
- const renderItem = (item: SuggestionItem) => {
- return (
- {
- if (selectedFirstKey) {
- onSelect?.(selectedFirstKey, item.value);
- setSelectedFirstKey(undefined);
- } else {
- clearSearch(item.value);
- setSelectedFirstKey(item.value);
- }
- }}
- >
-
-
-
{item.icon}
-
- {renderItemText(item.label, inputRef.current?.input?.value)}
-
-
-
- {renderItemText(item.extra, inputRef.current?.input?.value)}
-
-
-
+ }, [items, searchResults, selectedFirstKey]);
+
+ const currentList = useMemo(
+ () => (selectedFirstKey ? secondLevelList : firstLevelList),
+ [firstLevelList, selectedFirstKey, secondLevelList],
);
- };
- const ListHeader = useMemo(() => {
- if (selectedFirstKey) {
- return (
-
- }
- onClick={() => {
+ const { attachedContexts } = useSnapshot(context.state);
+
+ const selectedContextMap = useMemo(
+ () => groupBy(attachedContexts, 'type'),
+ [attachedContexts],
+ );
+
+ const isSecondItemSelected = useCallback(
+ (val: string) => {
+ return (
+ selectedFirstKey &&
+ selectedContextMap?.[selectedFirstKey]?.some(
+ (secondItem) => secondItem.value === val,
+ )
+ );
+ },
+ [selectedFirstKey, selectedContextMap],
+ );
+
+ const clearSearch = useCallback(
+ (targetFirstKey: string) => {
+ if (inputRef.current?.input) {
+ inputRef.current.input.value = '';
+ }
+ setSearchResults(undefined);
+ onSearch?.(targetFirstKey, '');
+ },
+ [onSearch],
+ );
+
+ const handleMouseMove = useCallback(
+ throttle(() => {
+ setListPointerEvents('auto');
+ }, 300),
+ [],
+ );
+
+ // FIXME: disabled状态禁用对于一级菜单未适配
+ // Handle keyboard navigation
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ switch (event.key) {
+ case 'ArrowDown':
+ event.preventDefault();
+ setListPointerEvents('none');
+ setSelectedIndex((prev) =>
+ prev < currentList.length - 1 ? prev + 1 : 0,
+ );
+ break;
+ case 'ArrowUp':
+ event.preventDefault();
+ setListPointerEvents('none');
+ setSelectedIndex((prev) =>
+ prev > 0 ? prev - 1 : currentList.length - 1,
+ );
+ break;
+ case 'Enter':
+ event.preventDefault();
+ if (selectedIndex >= 0 && selectedIndex < currentList.length) {
+ const selectedItem = currentList[selectedIndex];
+
+ if (selectedItem.disabled) {
+ break;
+ }
+
+ if (selectedFirstKey) {
+ if (!isSecondItemSelected(selectedItem.value)) {
+ onSelect?.(
+ selectedFirstKey,
+ selectedItem.value,
+ selectedItem.contextItem,
+ );
+ setSelectedFirstKey(undefined);
+ onOpenChange?.(false);
+ onLostFocus?.();
+ }
+ } else {
+ clearSearch(selectedItem.value);
+ setSelectedFirstKey(selectedItem.value);
+ }
+ }
+ break;
+ case 'Escape':
+ event.preventDefault();
+ if (selectedFirstKey) {
setSelectedFirstKey(undefined);
- }}
- type="text"
- />
- {
- const searchResults = onSearch?.(
- selectedFirstKey,
- e.target.value,
- );
- if (searchResults) {
- setSearchResults(searchResults);
+ } else {
+ onOpenChange?.(false);
+ onLostFocus?.();
+ }
+ break;
+ }
+ },
+ [
+ currentList,
+ selectedFirstKey,
+ selectedIndex,
+ onSelect,
+ onOpenChange,
+ clearSearch,
+ onLostFocus,
+ ],
+ );
+
+ useImperativeHandle(ref, () => {
+ return {
+ triggerKeyDown: (e) => {
+ handleKeyDown(e);
+ },
+ };
+ });
+
+ const renderItem = (item: SuggestionItem, index: number) => {
+ const isSelected = selectedIndex === index;
+ const isFirstLevel = !selectedFirstKey;
+ const isSecondSeleted = isSecondItemSelected(item.value);
+
+ const isDisabled = !!item.disabled;
+
+ return (
+ setSelectedIndex(index)}
+ onClick={(e) => {
+ if (isDisabled) {
+ e.preventDefault();
+ } else {
+ if (selectedFirstKey) {
+ onSelect?.(selectedFirstKey, item.value, item.contextItem);
+ setSelectedFirstKey(undefined);
+ onOpenChange?.(false);
+ onLostFocus?.();
} else {
- setSearchResults(undefined);
+ clearSearch(item.value);
+ setSelectedFirstKey(item.value);
}
- }}
- placeholder={t('common.placeholder')}
- />
-
+ }
+ }}
+ >
+
+
+
{item.icon}
+
{
+ switch (selectedFirstKey) {
+ case ContextType.FILE:
+ return (
+
+ );
+ case ContextType.SLASH_COMMAND:
+ return (
+ item.contextItem && (
+
+ )
+ );
+ default:
+ return null;
+ }
+ }}
+ maxWidth={260}
+ showTip={isSelected}
+ placement="right"
+ />
+
+ {isFirstLevel &&
}
+ {!isFirstLevel && isSecondSeleted && (
+
+ )}
+
+
);
- } else {
+ };
+
+ const handleBackClick = useCallback(
+ () => setSelectedFirstKey(undefined),
+ [],
+ );
+
+ const handleInputChange = useCallback(
+ (e: React.ChangeEvent) => {
+ if (selectedFirstKey) {
+ const searchResults = onSearch?.(selectedFirstKey, e.target.value);
+ setSearchResults(searchResults || undefined);
+ }
+ },
+ [onSearch, selectedFirstKey],
+ );
+
+ const ListHeader = useMemo(() => {
+ if (selectedFirstKey) {
+ return (
+
+
+ {searchControl ? (
+
+ {
+ firstLevelList.find((item) => item.value === selectedFirstKey)
+ ?.label
+ }
+
+ ) : (
+
+ )}
+
+ );
+ }
return null;
- }
- }, [onSearch, selectedFirstKey]);
-
- // auto close popup when lost focus
- useEffect(() => {
- if (!open) return;
- function handleClickOutside(event: MouseEvent) {
- if (
- popupRef.current &&
- !popupRef.current.contains(event.target as Node)
- ) {
+ }, [
+ selectedFirstKey,
+ firstLevelList,
+ handleBackClick,
+ handleInputChange,
+ t,
+ searchControl,
+ ]);
+
+ // Combined effect for popup state management
+ useEffect(() => {
+ if (open) {
+ // Focus popup container when it opens
+ if (popupRef.current) {
+ popupRef.current.focus();
+ }
+
if (selectedFirstKey) {
- clearSearch(selectedFirstKey);
+ if (searchControl) {
+ searchControl.onSearchStart?.();
+ } else {
+ inputRef.current?.focus();
+ }
}
- setSelectedFirstKey(undefined);
- onOpenChange?.(false);
+ // Set default selection (first item)
+ setSelectedIndex(currentList.length > 0 ? 0 : -1);
}
- }
- document.addEventListener('mousedown', handleClickOutside);
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
- };
- }, [open, onOpenChange]);
-
- return (
- (
-
- {ListHeader}
-
-
- )}
- trigger={[]}
- arrow={false}
- styles={{
- body: {
- padding: 0,
- },
- }}
- >
- {children}
-
- );
-};
+ }, [open, selectedFirstKey, currentList, searchControl?.onSearchStart]);
+
+ useEffect(() => {
+ if (searchControl?.searchText) {
+ if (selectedFirstKey) {
+ const searchResults = onSearch?.(
+ selectedFirstKey,
+ searchControl?.searchText,
+ );
+ setSearchResults(searchResults || undefined);
+ }
+ }
+ }, [searchControl?.searchText, selectedFirstKey]);
+
+ // Handle click outside to close popup
+ useEffect(() => {
+ if (!open) return;
+
+ function handleClickOutside(event: MouseEvent) {
+ if (
+ popupRef.current &&
+ !popupRef.current.contains(event.target as Node)
+ ) {
+ if (selectedFirstKey) {
+ clearSearch(selectedFirstKey);
+ }
+ setSelectedFirstKey(undefined);
+ setSelectedIndex(-1);
+ onOpenChange?.(false);
+ onLostFocus?.();
+ }
+ }
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [open, onOpenChange, selectedFirstKey, onLostFocus]);
+
+ // Scroll selected item into view
+ useEffect(() => {
+ if (selectedIndex >= 0 && listRef.current) {
+ const selectedItem = listRef.current.querySelector(
+ `[data-index="${selectedIndex}"]`,
+ ) as HTMLElement;
+
+ if (selectedItem) {
+ const listContainer =
+ listRef.current.querySelector('.ant-list') || listRef.current;
+ const containerRect = listContainer.getBoundingClientRect();
+ const itemRect = selectedItem.getBoundingClientRect();
+ const isAbove = itemRect.top < containerRect.top;
+ const isBelow = itemRect.bottom > containerRect.bottom;
+
+ if (isAbove || isBelow) {
+ selectedItem.scrollIntoView({
+ behavior: 'auto',
+ block: 'nearest',
+ inline: 'nearest',
+ });
+ }
+ }
+ }
+ }, [selectedIndex]);
+
+ const offsetStyles = useMemo(() => {
+ if (offset) {
+ return {
+ ...offset,
+ position: 'relative',
+ };
+ } else {
+ return {};
+ }
+ }, [offset]);
+
+ return (
+ (
+
+ {ListHeader}
+
+
+
+ )}
+ trigger={[]}
+ arrow={false}
+ styles={{
+ body: {
+ padding: 0,
+ ...offsetStyles,
+ },
+ }}
+ >
+ {children}
+
+ );
+ },
+);
export default SuggestionList;
diff --git a/browser/src/components/ToolApprovalConfirmation/index.tsx b/browser/src/components/ToolApprovalConfirmation/index.tsx
deleted file mode 100644
index 6e2581155..000000000
--- a/browser/src/components/ToolApprovalConfirmation/index.tsx
+++ /dev/null
@@ -1,245 +0,0 @@
-import { CheckOutlined, CloseOutlined, EyeOutlined } from '@ant-design/icons';
-import { Button, Flex, Tag, Typography } from 'antd';
-import { createStyles } from 'antd-style';
-import { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useSnapshot } from 'valtio';
-import { toolApprovalActions, toolApprovalState } from '@/state/toolApproval';
-import type { ToolApprovalRequestMessage } from '@/types/message';
-
-const { Text } = Typography;
-
-const useStyle = createStyles(({ token, css }) => {
- return {
- container: css`
- padding: ${token.paddingMD}px 0;
- border-left: 3px solid ${token.colorWarning};
- padding-left: ${token.paddingMD}px;
- background: ${token.colorWarningBg};
- border-radius: 0 ${token.borderRadiusLG}px ${token.borderRadiusLG}px 0;
- margin: ${token.marginXS}px 0;
- `,
- title: css`
- color: ${token.colorWarningText};
- font-weight: 500;
- margin-bottom: ${token.marginXS}px;
- `,
- description: css`
- color: ${token.colorTextSecondary};
- margin-bottom: ${token.marginMD}px;
- `,
- toolInfo: css`
- background: ${token.colorBgContainer};
- border-radius: ${token.borderRadius}px;
- padding: ${token.paddingSM}px;
- margin-bottom: ${token.marginMD}px;
- `,
- params: css`
- background: ${token.colorFillQuaternary};
- border-radius: ${token.borderRadius}px;
- padding: ${token.paddingSM}px;
- margin-top: ${token.marginXS}px;
- font-family: ${token.fontFamilyCode};
- font-size: ${token.fontSizeSM}px;
- max-height: 120px;
- overflow-y: auto;
- white-space: pre-wrap;
- `,
- errorAlert: css`
- background: ${token.colorErrorBg};
- border: 1px solid ${token.colorErrorBorder};
- border-radius: ${token.borderRadius}px;
- padding: ${token.paddingSM}px;
- margin-bottom: ${token.marginMD}px;
- `,
- loadingIndicator: css`
- color: ${token.colorPrimary};
- font-size: ${token.fontSizeSM}px;
- `,
- buttonGroup: css`
- gap: ${token.marginXS}px;
- flex-wrap: wrap;
- `,
- };
-});
-
-interface ToolApprovalConfirmationProps {
- message: ToolApprovalRequestMessage;
-}
-
-export default function ToolApprovalConfirmation({
- message,
-}: ToolApprovalConfirmationProps) {
- const { t } = useTranslation();
- const { styles } = useStyle();
- const snap = useSnapshot(toolApprovalState);
- const [showParams, setShowParams] = useState(false);
-
- // 检查当前消息是否为当前待处理的请求
- if (
- !snap.currentRequest ||
- snap.currentRequest.toolCallId !== message.toolCallId
- ) {
- return null;
- }
-
- // 格式化工具参数描述
- const getToolDescription = (
- toolName: string,
- params: Record,
- ) => {
- switch (toolName) {
- case 'read':
- return t('toolApproval.toolDescriptions.read', {
- filePath: params.file_path,
- });
- case 'bash':
- return t('toolApproval.toolDescriptions.bash', {
- command: params.command,
- });
- case 'edit':
- return t('toolApproval.toolDescriptions.edit', {
- filePath: params.file_path,
- });
- case 'write':
- return t('toolApproval.toolDescriptions.write', {
- filePath: params.file_path,
- });
- case 'fetch':
- return t('toolApproval.toolDescriptions.fetch', { url: params.url });
- case 'glob':
- return t('toolApproval.toolDescriptions.glob', {
- pattern: params.pattern,
- });
- case 'grep':
- return t('toolApproval.toolDescriptions.grep', {
- pattern: params.pattern,
- });
- case 'ls':
- return t('toolApproval.toolDescriptions.ls', {
- dirPath: params.dir_path,
- });
- default:
- return t('toolApproval.toolDescriptions.default', { toolName });
- }
- };
-
- const onApprove = (option: 'once' | 'always' | 'always_tool') => {
- toolApprovalActions.approveToolUse(true, option);
- };
-
- const onDeny = () => {
- toolApprovalActions.approveToolUse(false);
- };
-
- const onRetry = () => {
- toolApprovalActions.retrySubmit();
- };
-
- const description = getToolDescription(message.toolName, message.args);
- const isSubmitting = snap.submitting;
- const hasError = !!snap.submitError;
-
- return (
-
- {/* 标题 */}
-
- 🔐 {t('toolApproval.title', '工具执行权限确认')}
-
-
- {/* 描述 */}
-
- {t('toolApproval.description', 'AI 请求执行以下工具,是否允许?')}
-
-
- {/* 工具信息 */}
-
-
-
- {message.toolName}
- {description}
-
- }
- onClick={() => setShowParams(!showParams)}
- >
- {showParams
- ? t('toolApproval.hideParameters')
- : t('toolApproval.showParameters')}
-
-
-
- {showParams && (
-
- {JSON.stringify(message.args, null, 2)}
-
- )}
-
-
- {/* 错误提示 */}
- {hasError && (
-
-
-
-
-
- {t('toolApproval.submitFailed')}
-
-
- {snap.submitError}
-
-
-
-
- {t('toolApproval.retry')}
-
-
-
- )}
-
- {/* 提交中提示 */}
- {isSubmitting && (
-
- ⏳ {t('toolApproval.submitting')}
-
- )}
-
- {/* 操作按钮 */}
-
- }
- onClick={() => onApprove('once')}
- disabled={isSubmitting}
- size="small"
- >
- {t('toolApproval.approveOnce', '允许一次')}
-
-
- onApprove('always')}
- disabled={isSubmitting}
- size="small"
- >
- {t('toolApproval.approveAlways', '允许此命令')}
-
-
- onApprove('always_tool')}
- disabled={isSubmitting}
- size="small"
- >
- {t('toolApproval.approveAlwaysTool', `允许 ${message.toolName}`, {
- toolName: message.toolName,
- })}
-
-
-
- {t('toolApproval.deny', '拒绝')}
-
-
-
- );
-}
diff --git a/browser/src/components/ToolApprovalError/index.tsx b/browser/src/components/ToolApprovalError/index.tsx
deleted file mode 100644
index 511f9b41f..000000000
--- a/browser/src/components/ToolApprovalError/index.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import {
- ClockCircleOutlined,
- ExclamationCircleOutlined,
-} from '@ant-design/icons';
-import { Alert, Card, Space, Typography } from 'antd';
-import { useTranslation } from 'react-i18next';
-import type { ToolApprovalErrorMessage } from '@/types/message';
-
-const { Text } = Typography;
-
-interface ToolApprovalErrorProps {
- message: ToolApprovalErrorMessage;
-}
-
-export default function ToolApprovalError({ message }: ToolApprovalErrorProps) {
- const { t } = useTranslation();
-
- const formatTime = (timestamp: number) => {
- return new Date(timestamp).toLocaleTimeString();
- };
-
- return (
-
-
- {t('toolApproval.error', '工具审批错误')}
-
- }
- >
-
-
- {t('toolApproval.toolName', '工具名称')}:
- {message.toolName}
-
-
-
-
-
-
-
- {formatTime(message.timestamp)}
-
-
-
-
- );
-}
diff --git a/browser/src/components/ToolApprovalResult/index.tsx b/browser/src/components/ToolApprovalResult/index.tsx
deleted file mode 100644
index 79c484aa8..000000000
--- a/browser/src/components/ToolApprovalResult/index.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
-import { Card, Space, Tag, Typography } from 'antd';
-import { useTranslation } from 'react-i18next';
-import type { ToolApprovalResultMessage } from '@/types/message';
-
-const { Text } = Typography;
-
-interface ToolApprovalResultProps {
- message: ToolApprovalResultMessage;
-}
-
-export default function ToolApprovalResult({
- message,
-}: ToolApprovalResultProps) {
- const { t } = useTranslation();
-
- const getApprovalOptionText = (option?: string) => {
- switch (option) {
- case 'once':
- return t('toolApproval.approveOnce', '允许(一次)');
- case 'always':
- return t('toolApproval.approveAlways', '允许(此命令总是)');
- case 'always_tool':
- return t(
- 'toolApproval.approveAlwaysTool',
- `允许(${message.toolName} 总是)`,
- );
- default:
- return '';
- }
- };
-
- return (
-
- {message.approved ? (
-
- ) : (
-
- )}
-
- {message.approved
- ? t('toolApproval.approved', '工具执行已批准')
- : t('toolApproval.denied', '工具执行已拒绝')}
-
-
- }
- >
-
-
- {t('toolApproval.toolName', '工具名称')}:
- {message.toolName}
-
-
- {message.approved && message.option && (
-
- {t('toolApproval.approvalOption', '批准选项')}:
- {getApprovalOptionText(message.option)}
-
- )}
-
-
- );
-}
diff --git a/browser/src/components/ToolRender/BashRender.tsx b/browser/src/components/ToolRender/BashRender.tsx
deleted file mode 100644
index d64557aea..000000000
--- a/browser/src/components/ToolRender/BashRender.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import { RightOutlined } from '@ant-design/icons';
-import { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { BsTerminal } from 'react-icons/bs';
-import type { ToolMessage } from '@/types/message';
-import type { IBashToolResult } from '@/types/tool';
-import { ToolStatus } from './ToolStatus';
-
-export default function BashRender({ message }: { message?: ToolMessage }) {
- if (!message) return null;
- const { args, result, state } = message;
- const [isExpanded, setIsExpanded] = useState(false);
- const { t } = useTranslation();
-
- const toggleExpand = () => {
- setIsExpanded(!isExpanded);
- };
-
- const command = (args?.command as string) || '';
- const { stdout, stderr } = (result?.data || {}) as IBashToolResult;
- const output = (result as { stdout?: string })?.stdout;
-
- const renderContent = () => {
- if (output) {
- return {output} ;
- }
- if (!stdout && !stderr) {
- return (
-
- {t('toolRenders.bash.noOutput')}
-
- );
- }
- return (
-
- {stdout && (
-
{stdout}
- )}
- {stderr && (
-
- {stderr}
-
- )}
-
- );
- };
-
- return (
-
-
-
-
-
-
-
- {command}
-
-
-
-
-
-
-
- {renderContent()}
-
-
-
- );
-}
diff --git a/browser/src/components/ToolRender/BashRender/index.tsx b/browser/src/components/ToolRender/BashRender/index.tsx
new file mode 100644
index 000000000..6947eaba3
--- /dev/null
+++ b/browser/src/components/ToolRender/BashRender/index.tsx
@@ -0,0 +1,56 @@
+import { CheckOutlined } from '@ant-design/icons';
+import { useEffect, useState } from 'react';
+import CodeRenderer from '@/components/CodeRenderer';
+import MessageWrapper from '@/components/MessageWrapper';
+import { useClipboard } from '@/hooks/useClipboard';
+import BashIcon from '@/icons/bash.svg?react';
+import CopyIcon from '@/icons/copy.svg?react';
+import type { UIToolPart } from '@/types/chat';
+
+export default function BashRender({ part }: { part: UIToolPart }) {
+ const { input, result } = part;
+ const command = (input?.command as string) || '';
+ const llmContent = result?.llmContent as string;
+
+ const { writeText } = useClipboard();
+ const [isCopySuccess, setIsCopySuccess] = useState(false);
+
+ const handleCopy = () => {
+ writeText(llmContent || '');
+ setIsCopySuccess(true);
+ };
+
+ useEffect(() => {
+ if (isCopySuccess) {
+ const timer = setTimeout(() => {
+ setIsCopySuccess(false);
+ }, 2000);
+ return () => clearTimeout(timer);
+ }
+ }, [isCopySuccess]);
+
+ return (
+ }
+ showExpandIcon={false}
+ expandable={false}
+ expanded={!!llmContent?.length}
+ actions={[
+ {
+ key: 'copy',
+ icon: isCopySuccess ? : ,
+ onClick: handleCopy,
+ },
+ ]}
+ >
+ {llmContent ? (
+
+ ) : null}
+
+ );
+}
diff --git a/browser/src/components/ToolRender/DebugInfo.tsx b/browser/src/components/ToolRender/DebugInfo.tsx
index 846a1887e..b2d29bef4 100644
--- a/browser/src/components/ToolRender/DebugInfo.tsx
+++ b/browser/src/components/ToolRender/DebugInfo.tsx
@@ -1,11 +1,11 @@
import { RightOutlined } from '@ant-design/icons';
import { useState } from 'react';
-import type { ToolMessage } from '@/types/message';
+import type { UIToolPart } from '@/types/chat';
-export default function DebugInfo({ message }: { message?: ToolMessage }) {
+export default function DebugInfo({ part }: { part?: UIToolPart }) {
const [isExpanded, setIsExpanded] = useState(false);
- if (!import.meta.env.DEV || !message) {
+ if (!import.meta.env.DEV || !part) {
return null;
}
@@ -34,7 +34,7 @@ export default function DebugInfo({ message }: { message?: ToolMessage }) {
}`}
>
- {JSON.stringify(message, null, 2)}
+ {JSON.stringify(part, null, 2)}
diff --git a/browser/src/components/ToolRender/EditRender.tsx b/browser/src/components/ToolRender/EditRender.tsx
deleted file mode 100644
index 18c3514c9..000000000
--- a/browser/src/components/ToolRender/EditRender.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { useEffect, useMemo } from 'react';
-import { useSnapshot } from 'valtio';
-import { fileChangesActions, fileChangesState } from '@/state/fileChanges';
-import type { ToolMessage } from '@/types/message';
-import CodeDiffOutline from '../CodeDiffOutline';
-
-export default function EditRender({ message }: { message?: ToolMessage }) {
- if (!message) {
- return null;
- }
- const { toolCallId, args, state } = message;
- const { file_path, old_string, new_string } = args as {
- file_path: string;
- old_string: string;
- new_string: string;
- };
-
- useEffect(() => {
- fileChangesActions.initFileState(file_path, [
- { toolCallId, old_string, new_string },
- ]);
- }, [file_path, toolCallId, old_string, new_string]);
-
- const { files } = useSnapshot(fileChangesState);
-
- const editStatus = useMemo(() => {
- return files[file_path]?.edits.find(
- (edit) => edit.toolCallId === toolCallId,
- )?.editStatus;
- }, [files, file_path, toolCallId]);
-
- return (
-
- );
-}
diff --git a/browser/src/components/ToolRender/EditRender/index.tsx b/browser/src/components/ToolRender/EditRender/index.tsx
new file mode 100644
index 000000000..f1770d2bf
--- /dev/null
+++ b/browser/src/components/ToolRender/EditRender/index.tsx
@@ -0,0 +1,24 @@
+import CodeDiffOutline from '@/components/CodeDiffOutline';
+import type { UIToolPart } from '@/types/chat';
+
+export default function EditRender({ part }: { part: UIToolPart }) {
+ const { id, input, state } = part;
+
+ const { file_path, old_string, new_string } = input as {
+ file_path: string;
+ old_string: string;
+ new_string: string;
+ };
+
+ return (
+
+ );
+}
diff --git a/browser/src/components/ToolRender/FailRender.tsx b/browser/src/components/ToolRender/FailRender.tsx
index b3f629ab9..96dcf982f 100644
--- a/browser/src/components/ToolRender/FailRender.tsx
+++ b/browser/src/components/ToolRender/FailRender.tsx
@@ -1,12 +1,21 @@
import { CloseCircleOutlined, RightOutlined } from '@ant-design/icons';
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import type { ToolMessage } from '@/types/message';
+import type { UIToolPart } from '@/types/chat';
-export default function FailRender({ message }: { message: ToolMessage }) {
+export default function FailRender({ part }: { part: UIToolPart }) {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(false);
+ const message = useMemo(() => {
+ const { result } = part;
+ let text = result?.returnDisplay || result?.llmContent;
+ if (typeof text !== 'string') {
+ text = JSON.stringify(text);
+ }
+ return text;
+ }, [part]);
+
const handleToggle = () => {
setIsExpanded(!isExpanded);
};
@@ -27,7 +36,7 @@ export default function FailRender({ message }: { message: ToolMessage }) {
-
{t('tool.callFailed', { toolName: message.toolName })}
+
{t('tool.callFailed', { toolName: part.name })}
{
- setIsExpanded(!isExpanded);
- };
-
- const url = (args?.url as string) || '';
- const { result: fetchResult, durationMs } = (result?.data ||
- {}) as IFetchToolResult;
-
- const renderContent = () => {
- return (
-
- );
- };
-
- return (
-
-
-
-
-
-
-
{url}
-
- {state === 'result' && {durationMs}ms }
-
-
-
-
-
- );
-}
diff --git a/browser/src/components/ToolRender/FetchRender/index.tsx b/browser/src/components/ToolRender/FetchRender/index.tsx
new file mode 100644
index 000000000..8a3e8f168
--- /dev/null
+++ b/browser/src/components/ToolRender/FetchRender/index.tsx
@@ -0,0 +1,59 @@
+import { LoadingOutlined } from '@ant-design/icons';
+import { useMemo } from 'react';
+import MessageWrapper from '@/components/MessageWrapper';
+import { useClipboard } from '@/hooks/useClipboard';
+import CopyIcon from '@/icons/copy.svg?react';
+import SearchIcon from '@/icons/search.svg?react';
+import type { UIToolPart } from '@/types/chat';
+import { jsonSafeParse } from '@/utils/message';
+
+export default function FetchRender({ part }: { part: UIToolPart }) {
+ const { input, state, result } = part;
+
+ const { writeText } = useClipboard();
+
+ const url = (input?.url as string) || '';
+ const prompt = (input?.prompt as string) || '';
+
+ const actions = useMemo(() => {
+ if (state === 'tool_result') {
+ return [
+ {
+ key: 'success',
+ icon:
,
+ onClick: () => {
+ writeText(url);
+ },
+ },
+ ];
+ }
+
+ return [
+ {
+ key: 'loading',
+ icon: (
+
+ ),
+ },
+ ];
+ }, [state]);
+
+ const llmContent = useMemo(() => {
+ if (typeof result?.llmContent === 'string') {
+ return jsonSafeParse(result?.llmContent)?.result;
+ }
+ return null;
+ }, [result?.llmContent]);
+
+ return (
+
}
+ title={`${prompt} ${url}`}
+ actions={actions}
+ >
+
+ {llmContent}
+
+
+ );
+}
diff --git a/browser/src/components/ToolRender/GlobRender.tsx b/browser/src/components/ToolRender/GlobRender.tsx
deleted file mode 100644
index 6a3e920f6..000000000
--- a/browser/src/components/ToolRender/GlobRender.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { CodeOutlined, RightOutlined } from '@ant-design/icons';
-import { useState } from 'react';
-import type { ToolMessage } from '@/types/message';
-import type { IGlobToolResult } from '@/types/tool';
-import InnerList, { type ListItem } from './InnerList';
-import { ToolStatus } from './ToolStatus';
-
-export default function GlobRender({ message }: { message?: ToolMessage }) {
- if (!message) return null;
-
- const { toolName, result, state } = message;
- const [isExpanded, setIsExpanded] = useState(true);
-
- const toggleExpand = () => {
- setIsExpanded(!isExpanded);
- };
-
- const renderContent = () => {
- if (typeof result === 'string') {
- return
{result} ;
- }
-
- if (result?.data) {
- const { filenames, message } = result.data as IGlobToolResult;
-
- const items: ListItem[] = filenames.map((filename) => ({
- name: filename,
- isDirectory: filename.endsWith('/'),
- }));
-
- return (
-
- {message &&
{message}
}
-
-
- );
- }
-
- if (typeof result === 'object' && result !== null) {
- return
{JSON.stringify(result, null, 2)} ;
- }
- return null;
- };
-
- return (
-
-
-
-
-
-
- {toolName}
-
-
-
-
- {renderContent()}
-
-
-
- );
-}
diff --git a/browser/src/components/ToolRender/GlobRender/index.tsx b/browser/src/components/ToolRender/GlobRender/index.tsx
new file mode 100644
index 000000000..e92161b9f
--- /dev/null
+++ b/browser/src/components/ToolRender/GlobRender/index.tsx
@@ -0,0 +1,32 @@
+import { useMemo } from 'react';
+import { useSnapshot } from 'valtio';
+import MessageWrapper from '@/components/MessageWrapper';
+import FolderIcon from '@/icons/folder.svg?react';
+import { state } from '@/state/chat';
+import type { UIToolPart } from '@/types/chat';
+import { jsonSafeParse } from '@/utils/message';
+import InnerList, { type ListItem } from '../LsRender/InnerList';
+
+export default function GlobRender({ part }: { part: UIToolPart }) {
+ const snap = useSnapshot(state);
+ const { name, result } = part;
+ const filenames = useMemo
(() => {
+ if (typeof result?.llmContent === 'string') {
+ return jsonSafeParse(result?.llmContent)?.filenames || [];
+ }
+ return [];
+ }, [result?.llmContent]);
+
+ const items = useMemo(() => {
+ return filenames.map((filename) => ({
+ name: snap.cwd ? filename.replace(snap.cwd, '') : filename,
+ isDirectory: filename.endsWith('/'),
+ }));
+ }, [filenames, snap.cwd]);
+
+ return (
+ } title={name}>
+
+
+ );
+}
diff --git a/browser/src/components/ToolRender/GrepRender.tsx b/browser/src/components/ToolRender/GrepRender.tsx
deleted file mode 100644
index 2559e7234..000000000
--- a/browser/src/components/ToolRender/GrepRender.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { RightOutlined } from '@ant-design/icons';
-import { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { VscSearch } from 'react-icons/vsc';
-import type { ToolMessage } from '@/types/message';
-import type { IGrepToolResult } from '@/types/tool';
-import InnerList, { type ListItem } from './InnerList';
-import { ToolStatus } from './ToolStatus';
-
-export default function GrepRender({ message }: { message?: ToolMessage }) {
- if (!message) return null;
- const { t } = useTranslation();
-
- const { result, args, state } = message;
- const [isExpanded, setIsExpanded] = useState(true);
- const { filenames, durationMs } = (result?.data || {}) as IGrepToolResult;
-
- const toggleExpand = () => {
- setIsExpanded(!isExpanded);
- };
-
- const renderContent = () => {
- if (!filenames?.length) return null;
- const items: ListItem[] = filenames.map((filename) => ({
- name: filename,
- isDirectory: filename.endsWith('/'),
- }));
- return ;
- };
-
- return (
-
-
-
-
-
-
-
-
- {t('toolRenders.grep.grep')}
-
- {(args?.pattern as string) || ''}
-
- {' '}
- {t('toolRenders.grep.inFiles', { count: filenames?.length || 0 })}
-
-
- {durationMs &&
{durationMs}ms
}
-
-
-
-
-
-
- {renderContent()}
-
-
-
- );
-}
diff --git a/browser/src/components/ToolRender/GrepRender/index.tsx b/browser/src/components/ToolRender/GrepRender/index.tsx
new file mode 100644
index 000000000..9719f5584
--- /dev/null
+++ b/browser/src/components/ToolRender/GrepRender/index.tsx
@@ -0,0 +1,42 @@
+import { useMemo } from 'react';
+import { useSnapshot } from 'valtio';
+import MessageWrapper from '@/components/MessageWrapper';
+import SearchIcon from '@/icons/grep-search.svg?react';
+import SuccessIcon from '@/icons/success.svg?react';
+import { state } from '@/state/chat';
+import type { UIToolPart } from '@/types/chat';
+import { formatParamsDescription, jsonSafeParse } from '@/utils/message';
+import type { ListItem } from '../LsRender/InnerList';
+import InnerList from '../LsRender/InnerList';
+
+export default function GrepRender({ part }: { part: UIToolPart }) {
+ const snap = useSnapshot(state);
+ const { result } = part;
+ const title = useMemo(() => {
+ return formatParamsDescription(part.input);
+ }, [part.input]);
+
+ const filenames = useMemo(() => {
+ if (typeof result?.llmContent === 'string') {
+ return jsonSafeParse(result?.llmContent)?.filenames || [];
+ }
+ return [];
+ }, [result?.llmContent]);
+
+ const items = useMemo(() => {
+ return filenames.map((filename) => ({
+ name: snap.cwd ? filename.replace(snap.cwd, '') : filename,
+ isDirectory: filename.endsWith('/'),
+ }));
+ }, [filenames, snap.cwd]);
+
+ return (
+ }
+ statusIcon={ }
+ title={title}
+ >
+
+
+ );
+}
diff --git a/browser/src/components/ToolRender/LsRender.tsx b/browser/src/components/ToolRender/LsRender.tsx
deleted file mode 100644
index 68067a546..000000000
--- a/browser/src/components/ToolRender/LsRender.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-import { FolderOutlined, RightOutlined } from '@ant-design/icons';
-import { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import type { ToolMessage } from '@/types/message';
-import InnerList, { type ListItem } from './InnerList';
-import { ToolStatus } from './ToolStatus';
-
-const parseLsResult = (result: unknown): ListItem[] => {
- if (typeof result !== 'string' || !result) return [];
-
- const lines = result.trim().split('\n');
- const rootItems: ListItem[] = [];
- const parentStack: ListItem[] = [];
-
- // Edge case for single line path from original code.
- if (lines.length === 1 && !lines[0].trim().startsWith('- ')) {
- const name = lines[0].trim();
- return [
- {
- name: name.endsWith('/') ? name.slice(0, -1) : name,
- isDirectory: name.endsWith('/'),
- },
- ];
- }
-
- lines.forEach((line) => {
- const match = line.match(/^(\s*)- (.*)/);
- if (!match) return;
-
- const indentation = match[1].length;
- const level = Math.floor(indentation / 2);
-
- let name = match[2];
- const isDirectory = name.endsWith('/');
- if (isDirectory) {
- name = name.slice(0, -1);
- }
-
- const newItem: ListItem = {
- name,
- isDirectory,
- children: isDirectory ? [] : undefined,
- };
-
- while (parentStack.length > level) {
- parentStack.pop();
- }
-
- if (parentStack.length === 0) {
- rootItems.push(newItem);
- } else {
- const parent = parentStack[parentStack.length - 1];
- parent.children?.push(newItem);
- }
-
- if (newItem.isDirectory) {
- parentStack.push(newItem);
- }
- });
-
- return rootItems;
-};
-
-export default function LsRender({ message }: { message?: ToolMessage }) {
- if (!message) return null;
-
- const { state } = message;
- const items = parseLsResult(message.result?.data);
- const dirPath = (message.args?.dir_path as string) || '';
- const { t } = useTranslation();
-
- const [isExpanded, setIsExpanded] = useState(true);
-
- const toggleExpand = () => {
- if (items.length > 0) {
- setIsExpanded(!isExpanded);
- }
- };
-
- let displayPath = dirPath;
- let itemsCount = items.length;
-
- if (items.length === 1 && items[0].isDirectory) {
- displayPath = items[0].name;
- itemsCount = items[0].children?.length || 0;
- }
-
- return (
-
-
-
-
-
-
-
- {t('toolRenders.ls.listedItems', {
- count: itemsCount,
- path: displayPath.split('/').pop() || displayPath,
- })}
-
-
-
-
0 ? 'max-h-40' : 'max-h-0'
- }`}
- >
-
-
-
- );
-}
diff --git a/browser/src/components/ToolRender/InnerList.tsx b/browser/src/components/ToolRender/LsRender/InnerList.tsx
similarity index 70%
rename from browser/src/components/ToolRender/InnerList.tsx
rename to browser/src/components/ToolRender/LsRender/InnerList.tsx
index e2b0ff4ce..fdaafab4d 100644
--- a/browser/src/components/ToolRender/InnerList.tsx
+++ b/browser/src/components/ToolRender/LsRender/InnerList.tsx
@@ -1,6 +1,3 @@
-/**
- * 渲染toolRender中涉及到list展示的组件
- */
import {
DatabaseOutlined,
FileImageOutlined,
@@ -9,9 +6,7 @@ import {
FileProtectOutlined,
FileTextOutlined,
FileZipOutlined,
- FolderOutlined,
LockOutlined,
- RightOutlined,
} from '@ant-design/icons';
import { useState } from 'react';
import { FaJava } from 'react-icons/fa';
@@ -31,6 +26,9 @@ import {
SiTypescript,
SiYaml,
} from 'react-icons/si';
+import FolderIcon from '@/icons/folder.svg?react';
+import RightArrowIcon from '@/icons/rightArrow.svg?react';
+import styles from './index.module.css';
export interface ListItem {
name: string;
@@ -46,7 +44,7 @@ interface InnerListProps {
const getIconForFile = (filename: string) => {
if (filename.endsWith('/')) {
- return ;
+ return ;
}
const extension = filename.split('.').pop()?.toLowerCase();
@@ -120,45 +118,52 @@ const RenderItem = ({
}) => {
const [isExpanded, setIsExpanded] = useState(false);
- const hasChildren = item.children && item.children.length > 0;
+ // flatten children count
+ const childrenCount =
+ item.children?.reduce(
+ (acc, child) => acc + (child.children?.length || 0),
+ 0,
+ ) || 0;
const toggleExpand = () => {
- if (hasChildren) {
+ if (childrenCount > 0) {
setIsExpanded(!isExpanded);
}
};
return (
<>
-
+
0 && isExpanded
+ ? 'rotate(90deg)'
+ : 'rotate(0deg)',
transition: 'transform 0.2s ease-in-out',
}}
>
- {hasChildren ? (
-
- ) : (
-
- )}
+ {childrenCount > 0 && }
-
- {item.isDirectory ? : getIconForFile(item.name)}
+
+ {item.isDirectory ? : getIconForFile(item.name)}
-
+
{showPath ? item.name : item.name.split('/').pop()}
+
+ {childrenCount > 0 ? childrenCount : null}
+
- {hasChildren && isExpanded && (
-