From 44bc9beec59abaec9011784ae210b3c7d957486c Mon Sep 17 00:00:00 2001 From: liuzhq Date: Wed, 25 Mar 2026 15:20:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(cowork):=20=E6=B7=BB=E5=8A=A0=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E7=B4=A2=E5=BC=95=E6=B5=AE=E5=B1=82=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=BF=AB=E9=80=9F=E8=B7=B3=E8=BD=AC=E5=88=B0=E6=8C=87?= =?UTF-8?q?=E5=AE=9A=E5=AF=B9=E8=AF=9D=E8=BD=AE=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/cowork/CoworkSessionDetail.tsx | 212 ++++++++++++++---- src/renderer/services/i18n.ts | 2 + 2 files changed, 166 insertions(+), 48 deletions(-) diff --git a/src/renderer/components/cowork/CoworkSessionDetail.tsx b/src/renderer/components/cowork/CoworkSessionDetail.tsx index e1b6ad5..9e7b429 100644 --- a/src/renderer/components/cowork/CoworkSessionDetail.tsx +++ b/src/renderer/components/cowork/CoworkSessionDetail.tsx @@ -1306,6 +1306,10 @@ const CoworkSessionDetail: React.FC = ({ const navigatingTimerRef = useRef | null>(null); const turnElsCacheRef = useRef([]); const [isScrollable, setIsScrollable] = useState(false); + const [showTurnIndexPopover, setShowTurnIndexPopover] = useState(false); + const showTurnIndexPopoverRef = useRef(false); + const turnIndexPopoverRef = useRef(null); + const turnIndexButtonRef = useRef(null); // Menu and action states const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null); @@ -1359,6 +1363,8 @@ const CoworkSessionDetail: React.FC = ({ turnElsCacheRef.current = []; if (hideNavTimerRef.current) clearTimeout(hideNavTimerRef.current); if (navigatingTimerRef.current) clearTimeout(navigatingTimerRef.current); + setShowTurnIndexPopover(false); + showTurnIndexPopoverRef.current = false; }, [currentSession?.id]); // Close menu on outside click @@ -1678,7 +1684,9 @@ const CoworkSessionDetail: React.FC = ({ setShowTurnNav((prev) => (prev ? prev : true)); if (hideNavTimerRef.current) clearTimeout(hideNavTimerRef.current); - hideNavTimerRef.current = setTimeout(() => setShowTurnNav(false), NAV_HIDE_DELAY); + if (!showTurnIndexPopoverRef.current) { + hideNavTimerRef.current = setTimeout(() => setShowTurnNav(false), NAV_HIDE_DELAY); + } // Skip index recalculation during programmatic navigation if (isNavigatingRef.current) return; @@ -1708,28 +1716,31 @@ const CoworkSessionDetail: React.FC = ({ setCurrentTurnIndex(visibleIndex); }, []); + const navigateToTurnByIndex = useCallback((index: number) => { + const turnEls = turnElsCacheRef.current; + if (index < 0 || index >= turnEls.length) return; + + isNavigatingRef.current = true; + if (navigatingTimerRef.current) clearTimeout(navigatingTimerRef.current); + navigatingTimerRef.current = setTimeout(() => { isNavigatingRef.current = false; }, NAV_SCROLL_LOCK_DURATION); + + turnEls[index].scrollIntoView({ behavior: 'smooth', block: 'start' }); + currentTurnIndexRef.current = index; + setCurrentTurnIndex(index); + + setShowTurnNav(true); + if (hideNavTimerRef.current) clearTimeout(hideNavTimerRef.current); + hideNavTimerRef.current = setTimeout(() => setShowTurnNav(false), NAV_HIDE_DELAY); + }, []); + const navigateToTurn = useCallback((direction: 'prev' | 'next') => { const turnEls = turnElsCacheRef.current; if (turnEls.length === 0) return; const idx = currentTurnIndexRef.current; const targetIndex = direction === 'prev' ? idx - 1 : idx + 1; if (targetIndex < 0 || targetIndex >= turnEls.length) return; - - // Block scroll handler from overriding index during smooth scroll - isNavigatingRef.current = true; - if (navigatingTimerRef.current) clearTimeout(navigatingTimerRef.current); - navigatingTimerRef.current = setTimeout(() => { isNavigatingRef.current = false; }, NAV_SCROLL_LOCK_DURATION); - - turnEls[targetIndex].scrollIntoView({ behavior: 'smooth', block: 'start' }); - currentTurnIndexRef.current = targetIndex; - setCurrentTurnIndex(targetIndex); - // Reset hide timer - setShowTurnNav(true); - if (hideNavTimerRef.current) clearTimeout(hideNavTimerRef.current); - hideNavTimerRef.current = setTimeout(() => setShowTurnNav(false), NAV_HIDE_DELAY); - }, []); - - // Get the last message content for auto-scroll on streaming updates + navigateToTurnByIndex(targetIndex); + }, [navigateToTurnByIndex]); const lastMessage = currentSession?.messages?.[currentSession.messages.length - 1]; const lastMessageContent = lastMessage?.content; @@ -1789,6 +1800,60 @@ const CoworkSessionDetail: React.FC = ({ ); }, [turns]); + // Close turn index popover on click-outside or Escape + useEffect(() => { + if (!showTurnIndexPopover) return; + + const handleClickOutside = (e: MouseEvent) => { + if ( + turnIndexPopoverRef.current && !turnIndexPopoverRef.current.contains(e.target as Node) + && !turnIndexButtonRef.current?.contains(e.target as Node) + ) { + setShowTurnIndexPopover(false); + } + }; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') setShowTurnIndexPopover(false); + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [showTurnIndexPopover]); + + // Auto-scroll popover to bottom (show latest conversations) when opened + useEffect(() => { + if (showTurnIndexPopover && turnIndexPopoverRef.current) { + turnIndexPopoverRef.current.scrollTop = turnIndexPopoverRef.current.scrollHeight; + } + }, [showTurnIndexPopover]); + + // Sync popover ref and manage hide timer based on popover visibility + useEffect(() => { + showTurnIndexPopoverRef.current = showTurnIndexPopover; + if (showTurnIndexPopover) { + // Popover opened — clear hide timer to keep nav visible + if (hideNavTimerRef.current) { + clearTimeout(hideNavTimerRef.current); + hideNavTimerRef.current = null; + } + } else { + // Popover closed — restart hide timer + if (showTurnNav) { + if (hideNavTimerRef.current) clearTimeout(hideNavTimerRef.current); + hideNavTimerRef.current = setTimeout(() => setShowTurnNav(false), NAV_HIDE_DELAY); + } + } + }, [showTurnIndexPopover, showTurnNav]); + + // Close popover when nav hides + useEffect(() => { + if (!showTurnNav) setShowTurnIndexPopover(false); + }, [showTurnNav]); + // Auto scroll to bottom when new messages arrive or content updates (streaming) useEffect(() => { if (!shouldAutoScroll) { @@ -2043,39 +2108,90 @@ const CoworkSessionDetail: React.FC = ({ {/* Turn Navigation Buttons */} {turns.length > 1 && isScrollable && ( -
+
- -
- -
+ +
+ +
+ +
+ + {/* Turn Index Popover */} + {showTurnIndexPopover && ( +
+ {turns.map((turn, index) => { + const label = turn.userMessage?.content + ? turn.userMessage.content.split('\n')[0].slice(0, 60) + : '...'; + const isActive = index === currentTurnIndex; + return ( + + ); + })} +
+ )} + )}
diff --git a/src/renderer/services/i18n.ts b/src/renderer/services/i18n.ts index 981af89..d96b7e7 100644 --- a/src/renderer/services/i18n.ts +++ b/src/renderer/services/i18n.ts @@ -458,6 +458,7 @@ const translations: Record> = { coworkExportImageSuccess: '图片导出成功', coworkExportImageFailed: '导出图片失败', deleteSession: '删除任务', + turnIndex: '对话索引', deleteTaskConfirmTitle: '确认删除任务', deleteTaskConfirmMessage: '此操作无法撤销,任务的所有消息记录将被永久删除。', batchOperations: '批量操作', @@ -1562,6 +1563,7 @@ const translations: Record> = { coworkExportImageSuccess: 'Image exported successfully', coworkExportImageFailed: 'Failed to export image', deleteSession: 'Delete Task', + turnIndex: 'Turn index', deleteTaskConfirmTitle: 'Confirm Deletion', deleteTaskConfirmMessage: 'This action cannot be undone. All messages in this task will be permanently deleted.', batchOperations: 'Batch Operations',