From 108ddfee880e33a04d93d25d41b6c527f67eafbe Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Wed, 3 Jun 2026 22:30:31 +0800 Subject: [PATCH] feat(wework): add conversation search navigation --- backend/app/api/endpoints/adapter/tasks.py | 32 +- .../adapters/wework_conversation_search.py | 216 ++++++++++ .../adapters/test_task_archive_operations.py | 145 +++++++ wework/src/api/adapters.test.ts | 11 + wework/src/api/tasks.ts | 12 + .../components/layout/DesktopSearchDialog.tsx | 394 ++++++++++++++++++ .../src/components/layout/DesktopSidebar.tsx | 83 +++- .../layout/DesktopWorkbenchLayout.test.tsx | 254 ++++++++++- .../layout/DesktopWorkbenchLayout.tsx | 14 +- .../features/workbench/WorkbenchProvider.tsx | 22 +- wework/src/i18n/locales/en/common.json | 3 + wework/src/i18n/locales/zh-CN/common.json | 3 + wework/src/pages/WorkbenchPage.tsx | 4 + 13 files changed, 1186 insertions(+), 7 deletions(-) create mode 100644 backend/app/services/adapters/wework_conversation_search.py create mode 100644 wework/src/components/layout/DesktopSearchDialog.tsx diff --git a/backend/app/api/endpoints/adapter/tasks.py b/backend/app/api/endpoints/adapter/tasks.py index e92c0b64127..5e8c84da3b9 100644 --- a/backend/app/api/endpoints/adapter/tasks.py +++ b/backend/app/api/endpoints/adapter/tasks.py @@ -17,7 +17,11 @@ from app.api.dependencies import get_db, with_task_telemetry from app.core import security from app.core.config import settings -from app.core.constants import CLIENT_ORIGIN_FRONTEND, SUPPORTED_CLIENT_ORIGINS +from app.core.constants import ( + CLIENT_ORIGIN_FRONTEND, + CLIENT_ORIGIN_WEWORK, + SUPPORTED_CLIENT_ORIGINS, +) from app.db.session import get_async_db from app.models.user import User from app.schemas.remote_workspace import ( @@ -57,6 +61,9 @@ from app.services import prompt_draft_service from app.services.adapters.executor_job import job_service from app.services.adapters.task_kinds import task_kinds_service +from app.services.adapters.wework_conversation_search import ( + search_wework_conversation_tasks, +) from app.services.chat.storage import session_manager from app.services.remote_workspace_service import remote_workspace_service from app.services.shared_task import shared_task_service @@ -274,6 +281,29 @@ def search_tasks_by_title( return {"total": total, "items": items} +@router.get("/wework/conversation-search", response_model=TaskListResponse) +def search_wework_conversation_task_list( + keyword: str = Query( + ..., min_length=1, description="Search by task title or message keywords" + ), + page: int = Query(1, ge=1, description="Page number"), + limit: int = Query(10, ge=1, le=100, description="Items per page"), + current_user: User = Depends(security.get_current_user), + db: Session = Depends(get_db), +): + """Search WeWork conversations by title or conversation content.""" + skip = (page - 1) * limit + items, total = search_wework_conversation_tasks( + db=db, + user_id=current_user.id, + keyword=keyword, + skip=skip, + limit=limit, + client_origin=CLIENT_ORIGIN_WEWORK, + ) + return {"total": total, "items": items} + + @router.get("/archived", response_model=ArchivedTaskListResponse) def get_archived_tasks( page: int = Query(1, ge=1, description="Page number"), diff --git a/backend/app/services/adapters/wework_conversation_search.py b/backend/app/services/adapters/wework_conversation_search.py new file mode 100644 index 00000000000..9a00ff65655 --- /dev/null +++ b/backend/app/services/adapters/wework_conversation_search.py @@ -0,0 +1,216 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""WeWork-specific conversation search service.""" + +import json +from typing import Any, Dict, List, Tuple + +from sqlalchemy import String, cast, or_ +from sqlalchemy.orm import Session + +from app.core.constants import CLIENT_ORIGIN_WEWORK +from app.models.subtask import Subtask +from app.models.task import TaskResource +from app.schemas.kind import Task +from app.services.adapters.task_kinds.converters import convert_to_task_dict_optimized +from app.services.adapters.task_kinds.filters import filter_tasks_for_display +from app.services.adapters.task_kinds.helpers import get_tasks_related_data_batch +from app.services.adapters.task_kinds.query_utils import restore_task_order + +SEARCH_RESULT_FALLBACK_TASK_LIMIT = 300 +SEARCH_RESULT_FALLBACK_SUBTASK_LIMIT = 1000 + + +def _stringify_search_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False, default=str) + + +def _get_subtask_search_text(subtask: Subtask) -> str: + return "\n".join( + [ + _stringify_search_value(subtask.prompt), + _stringify_search_value(subtask.result), + _stringify_search_value(subtask.error_message), + ] + ) + + +def search_wework_conversation_tasks( + *, + db: Session, + user_id: int, + keyword: str, + skip: int = 0, + limit: int = 100, + client_origin: str = CLIENT_ORIGIN_WEWORK, +) -> Tuple[List[Dict[str, Any]], int]: + """Search WeWork-owned tasks by title or conversation content.""" + keyword = keyword.strip() + if not keyword: + return [], 0 + + task_ids, visible_task_by_id = _load_visible_wework_tasks( + db=db, + user_id=user_id, + client_origin=client_origin, + ) + if not task_ids: + return [], 0 + + matched_task_ids, ordered_matched_task_ids = _collect_matched_task_ids( + db=db, + task_ids=task_ids, + visible_task_by_id=visible_task_by_id, + keyword=keyword, + required_count=skip + limit, + ) + id_to_task = { + task_id: visible_task_by_id[task_id] + for task_id in matched_task_ids + if task_id in visible_task_by_id + } + total = len(id_to_task) + filtered_tasks = restore_task_order( + ordered_matched_task_ids[skip : skip + limit], id_to_task, limit + ) + if not filtered_tasks: + return [], total + + return _format_task_results(db, filtered_tasks, user_id), total + + +def _collect_matched_task_ids( + *, + db: Session, + task_ids: List[int], + visible_task_by_id: Dict[int, TaskResource], + keyword: str, + required_count: int, +) -> Tuple[set[int], List[int]]: + keyword_lower = keyword.lower() + matched_task_ids = _match_task_titles(visible_task_by_id, keyword_lower) + matched_task_ids.update(_match_subtasks_in_database(db, task_ids, keyword)) + ordered_matched_task_ids = [ + task_id for task_id in task_ids if task_id in matched_task_ids + ] + + if len(ordered_matched_task_ids) < required_count: + matched_task_ids.update( + _match_subtasks_with_python_fallback( + db=db, + task_ids=task_ids, + matched_task_ids=matched_task_ids, + keyword_lower=keyword_lower, + ) + ) + ordered_matched_task_ids = [ + task_id for task_id in task_ids if task_id in matched_task_ids + ] + + return matched_task_ids, ordered_matched_task_ids + + +def _load_visible_wework_tasks( + *, + db: Session, + user_id: int, + client_origin: str, +) -> Tuple[List[int], Dict[int, TaskResource]]: + tasks = ( + db.query(TaskResource) + .filter( + TaskResource.kind == "Task", + TaskResource.is_active == TaskResource.STATE_ACTIVE, + TaskResource.namespace != "system", + TaskResource.user_id == user_id, + TaskResource.client_origin == client_origin, + ) + .order_by(TaskResource.updated_at.desc()) + .all() + ) + visible_task_by_id = filter_tasks_for_display(tasks) + task_ids = [task.id for task in tasks if task.id in visible_task_by_id] + return task_ids, visible_task_by_id + + +def _format_task_results( + db: Session, + filtered_tasks: List[TaskResource], + user_id: int, +) -> List[Dict[str, Any]]: + related_data_batch = get_tasks_related_data_batch(db, filtered_tasks, user_id) + result = [] + for task in filtered_tasks: + task_crd = Task.model_validate(task.json) + task_related_data = related_data_batch.get(str(task.id), {}) + result.append(convert_to_task_dict_optimized(task, task_related_data, task_crd)) + + return result + + +def _match_task_titles( + visible_task_by_id: Dict[int, TaskResource], + keyword_lower: str, +) -> set[int]: + return { + task.id + for task in visible_task_by_id.values() + if keyword_lower in (Task.model_validate(task.json).spec.title or "").lower() + } + + +def _match_subtasks_in_database( + db: Session, + task_ids: List[int], + keyword: str, +) -> set[int]: + like_pattern = f"%{keyword}%" + rows = ( + db.query(Subtask.task_id) + .filter(Subtask.task_id.in_(task_ids)) + .filter( + or_( + Subtask.prompt.ilike(like_pattern), + Subtask.error_message.ilike(like_pattern), + cast(Subtask.result, String).ilike(like_pattern), + ) + ) + .distinct() + .all() + ) + return {row[0] for row in rows} + + +def _match_subtasks_with_python_fallback( + *, + db: Session, + task_ids: List[int], + matched_task_ids: set[int], + keyword_lower: str, +) -> set[int]: + fallback_task_ids = [ + task_id + for task_id in task_ids[:SEARCH_RESULT_FALLBACK_TASK_LIMIT] + if task_id not in matched_task_ids + ] + if not fallback_task_ids: + return set() + + subtasks = ( + db.query(Subtask) + .filter(Subtask.task_id.in_(fallback_task_ids)) + .order_by(Subtask.updated_at.desc()) + .limit(SEARCH_RESULT_FALLBACK_SUBTASK_LIMIT) + .all() + ) + return { + subtask.task_id + for subtask in subtasks + if keyword_lower in _get_subtask_search_text(subtask).lower() + } diff --git a/backend/tests/services/adapters/test_task_archive_operations.py b/backend/tests/services/adapters/test_task_archive_operations.py index 649f7af4219..09319128a95 100644 --- a/backend/tests/services/adapters/test_task_archive_operations.py +++ b/backend/tests/services/adapters/test_task_archive_operations.py @@ -11,11 +11,15 @@ from sqlalchemy.orm import Session from app.models.project import Project +from app.models.subtask import Subtask from app.models.task import TaskResource from app.models.user import User from app.schemas.task import TaskCreate from app.services import project_service from app.services.adapters.task_kinds import task_kinds_service +from app.services.adapters.wework_conversation_search import ( + search_wework_conversation_tasks, +) from app.services.chat.standalone_workspace import ( WORKSPACE_PATH_LABEL, WORKSPACE_SOURCE_LABEL, @@ -167,6 +171,147 @@ def test_archive_task_hides_from_personal_list_and_preserves_timestamp( assert archived_items[0].updated_at == original_updated_at +def test_title_search_does_not_match_subtask_content( + test_db: Session, + test_user: User, +): + task = _create_task( + test_db, + test_user.id, + 7109, + "hi", + client_origin="wework", + ) + test_db.add( + Subtask( + user_id=test_user.id, + task_id=task.id, + team_id=1, + title="assistant reply", + bot_ids=[], + prompt="ubuntu", + result={"value": "assistant reply"}, + completed_at=datetime(2026, 5, 30, 8, 1, 0), + ) + ) + test_db.commit() + + items, total = task_kinds_service.get_user_tasks_by_title_with_pagination( + db=test_db, + user_id=test_user.id, + title="ubuntu", + skip=0, + limit=10, + ) + + assert total == 0 + assert items == [] + + +def test_conversation_search_matches_subtask_content_and_client_origin( + test_db: Session, + test_user: User, +): + matched_task = _create_task( + test_db, + test_user.id, + 7111, + "hi", + client_origin="wework", + updated_at=datetime(2026, 5, 30, 9, 0, 0), + ) + _create_task( + test_db, + test_user.id, + 7112, + "hi", + client_origin="frontend", + updated_at=datetime(2026, 5, 30, 10, 0, 0), + ) + test_db.add_all( + [ + Subtask( + user_id=test_user.id, + task_id=matched_task.id, + team_id=1, + title="assistant reply", + bot_ids=[], + prompt="请总结这个人", + result={"value": "这是关于胡云鹏的回复内容"}, + completed_at=datetime(2026, 5, 30, 9, 1, 0), + ), + Subtask( + user_id=test_user.id, + task_id=7112, + team_id=1, + title="assistant reply", + bot_ids=[], + prompt="胡云鹏", + result={"value": "wrong client"}, + completed_at=datetime(2026, 5, 30, 10, 1, 0), + ), + ] + ) + test_db.commit() + + items, total = search_wework_conversation_tasks( + db=test_db, + user_id=test_user.id, + keyword="胡云鹏", + skip=0, + limit=10, + client_origin="wework", + ) + + assert total == 1 + assert [item["id"] for item in items] == [matched_task.id] + + +def test_conversation_search_uses_database_text_match_before_result_fallback( + test_db: Session, + test_user: User, +): + for index in range(12): + task = _create_task( + test_db, + test_user.id, + 7120 + index, + f"chat {index}", + client_origin="wework", + updated_at=datetime(2026, 5, 30, 10, index, 0), + ) + test_db.add( + Subtask( + user_id=test_user.id, + task_id=task.id, + team_id=1, + title="user message", + bot_ids=[], + prompt=f"ubuntu setup question {index}", + result={"value": "assistant reply"}, + completed_at=datetime(2026, 5, 30, 10, index, 30), + ) + ) + + test_db.commit() + + with patch( + "app.services.adapters.wework_conversation_search._get_subtask_search_text", + side_effect=AssertionError("Python result fallback should not run"), + ): + items, total = search_wework_conversation_tasks( + db=test_db, + user_id=test_user.id, + keyword="ubuntu", + skip=0, + limit=10, + client_origin="wework", + ) + + assert total == 12 + assert len(items) == 10 + + def test_archive_all_and_project_archive_only_archive_chat_like_tasks( test_db: Session, test_user: User, diff --git a/wework/src/api/adapters.test.ts b/wework/src/api/adapters.test.ts index 92f39ddc44e..3b1367b2c5d 100644 --- a/wework/src/api/adapters.test.ts +++ b/wework/src/api/adapters.test.ts @@ -50,6 +50,17 @@ describe('REST adapters', () => { expect(client.get).toHaveBeenCalledWith('/tasks/8?client_origin=wework') }) + test('searches conversation tasks in the wework client origin', async () => { + const client = mockClient() + vi.mocked(client.get).mockResolvedValueOnce({ total: 0, items: [] }) + + await createTaskApi(client).searchTasks('胡云鹏', { limit: 30 }) + + expect(client.get).toHaveBeenCalledWith( + '/tasks/wework/conversation-search?keyword=%E8%83%A1%E4%BA%91%E9%B9%8F&page=1&limit=30', + ) + }) + test('picks default team for wework first, then code and chat', async () => { const client = mockClient() vi.mocked(client.get).mockResolvedValueOnce({ diff --git a/wework/src/api/tasks.ts b/wework/src/api/tasks.ts index e877999d989..6eb8eb369fb 100644 --- a/wework/src/api/tasks.ts +++ b/wework/src/api/tasks.ts @@ -15,6 +15,11 @@ interface RecentTaskParams { page?: number } +interface SearchTaskParams { + limit?: number + page?: number +} + export function createTaskApi(client: HttpClient) { return { listRecentTasks(params: RecentTaskParams): Promise { @@ -25,6 +30,13 @@ export function createTaskApi(client: HttpClient) { query.set('client_origin', WEWORK_CLIENT_ORIGIN) return client.get(`/tasks/lite/personal?${query.toString()}`) }, + searchTasks(keyword: string, params: SearchTaskParams = {}): Promise { + const query = new URLSearchParams() + query.set('keyword', keyword) + query.set('page', String(params.page ?? 1)) + query.set('limit', String(params.limit ?? 20)) + return client.get(`/tasks/wework/conversation-search?${query.toString()}`) + }, getTaskDetail(taskId: number): Promise { return client.get(`/tasks/${taskId}?client_origin=${WEWORK_CLIENT_ORIGIN}`) }, diff --git a/wework/src/components/layout/DesktopSearchDialog.tsx b/wework/src/components/layout/DesktopSearchDialog.tsx new file mode 100644 index 00000000000..fa1ede93227 --- /dev/null +++ b/wework/src/components/layout/DesktopSearchDialog.tsx @@ -0,0 +1,394 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from '@/hooks/useTranslation' +import type { + ProjectTask, + ProjectWithTasks, + Subtask, + Task, + TaskDetail, + TaskListResponse, +} from '@/types/api' + +interface SearchDialogItem { + taskId: number + projectId?: number + title: string + projectName?: string + updatedAt?: string + createdAt?: string +} + +interface DesktopSearchDialogProps { + open: boolean + projects: ProjectWithTasks[] + recentTasks: Task[] + onOpenChange: (open: boolean) => void + onOpenTask: (taskId: number, projectId?: number) => void + onSearchTasks?: (query: string) => Promise + onSearchTaskDetail?: (taskId: number) => Promise +} + +function getProjectTaskTitle(task: ProjectTask) { + return task.task_title || task.title || `Task #${task.task_id}` +} + +function sortSearchItems(items: SearchDialogItem[]) { + return [...items].sort((left, right) => { + const leftTime = new Date(left.updatedAt || left.createdAt || 0).getTime() + const rightTime = new Date(right.updatedAt || right.createdAt || 0).getTime() + return rightTime - leftTime + }) +} + +function buildSearchItems(projects: ProjectWithTasks[], recentTasks: Task[]) { + const items = new Map() + + projects.forEach(project => { + project.tasks?.forEach(task => { + items.set(task.task_id, { + taskId: task.task_id, + projectId: project.id, + title: getProjectTaskTitle(task), + projectName: project.name, + updatedAt: task.updated_at, + createdAt: task.created_at, + }) + }) + }) + + recentTasks.forEach(task => { + if (items.has(task.id)) return + + const project = task.project_id + ? projects.find(candidate => candidate.id === task.project_id) + : undefined + + items.set(task.id, { + taskId: task.id, + projectId: task.project_id || undefined, + title: task.title, + projectName: project?.name, + updatedAt: task.updated_at, + createdAt: task.created_at, + }) + }) + + return sortSearchItems([...items.values()]) +} + +function buildRemoteSearchItems(projects: ProjectWithTasks[], tasks: Task[]) { + return sortSearchItems( + tasks.map(task => { + const project = task.project_id + ? projects.find(candidate => candidate.id === task.project_id) + : undefined + + return { + taskId: task.id, + projectId: task.project_id || undefined, + title: task.title, + projectName: project?.name, + updatedAt: task.updated_at, + createdAt: task.created_at, + } + }), + ) +} + +function mergeSearchItems( + localItems: SearchDialogItem[], + remoteItems: SearchDialogItem[], +) { + const items = new Map() + localItems.forEach(item => items.set(item.taskId, item)) + remoteItems.forEach(item => { + const existing = items.get(item.taskId) + items.set(item.taskId, { + ...item, + ...existing, + projectId: existing?.projectId ?? item.projectId, + projectName: existing?.projectName ?? item.projectName, + }) + }) + return sortSearchItems([...items.values()]) +} + +function getResultText(result: unknown) { + if (typeof result === 'string') return result + if (!result || typeof result !== 'object') return '' + + const value = (result as { value?: unknown }).value + if (typeof value === 'string') return value + + const error = (result as { error?: unknown }).error + if (typeof error === 'string') return error + + return '' +} + +function getSubtaskSearchText(subtask: Subtask) { + return [subtask.prompt, getResultText(subtask.result)].filter(Boolean).join('\n') +} + +function getTaskDetailSearchText(detail: TaskDetail) { + return detail.subtasks?.map(getSubtaskSearchText).join('\n') ?? '' +} + +export function DesktopSearchDialog({ + open, + projects, + recentTasks, + onOpenChange, + onOpenTask, + onSearchTasks, + onSearchTaskDetail, +}: DesktopSearchDialogProps) { + const { t } = useTranslation('common') + const inputRef = useRef(null) + const [query, setQuery] = useState('') + const [detailTextByTaskId, setDetailTextByTaskId] = useState>({}) + const [loadingDetailTaskIds, setLoadingDetailTaskIds] = useState>(() => new Set()) + const [remoteSearchState, setRemoteSearchState] = useState<{ + query: string + items: SearchDialogItem[] + loading: boolean + }>({ query: '', items: [], loading: false }) + const searchItems = useMemo( + () => buildSearchItems(projects, recentTasks), + [projects, recentTasks], + ) + const normalizedQuery = query.trim().toLowerCase() + const activeRemoteItems = useMemo( + () => + remoteSearchState.query === normalizedQuery ? remoteSearchState.items : [], + [normalizedQuery, remoteSearchState.items, remoteSearchState.query], + ) + const activeRemoteTaskIds = useMemo( + () => new Set(activeRemoteItems.map(item => item.taskId)), + [activeRemoteItems], + ) + const combinedSearchItems = useMemo( + () => mergeSearchItems(searchItems, activeRemoteItems), + [activeRemoteItems, searchItems], + ) + const filteredItems = useMemo(() => { + if (!normalizedQuery) return searchItems + + return combinedSearchItems.filter( + item => + activeRemoteTaskIds.has(item.taskId) || + [item.title, item.projectName, detailTextByTaskId[item.taskId]] + .filter(Boolean) + .some(value => value?.toLowerCase().includes(normalizedQuery)), + ) + }, [ + activeRemoteTaskIds, + combinedSearchItems, + detailTextByTaskId, + normalizedQuery, + searchItems, + ]) + const hasPendingDetailSearch = + Boolean(normalizedQuery && !onSearchTasks && onSearchTaskDetail) && + loadingDetailTaskIds.size > 0 + const hasPendingRemoteSearch = + Boolean(normalizedQuery && onSearchTasks) && + (remoteSearchState.query !== normalizedQuery || remoteSearchState.loading) + const hasPendingSearch = hasPendingDetailSearch || hasPendingRemoteSearch + + const closeDialog = useCallback(() => { + setQuery('') + setRemoteSearchState({ query: '', items: [], loading: false }) + onOpenChange(false) + }, [onOpenChange]) + + useEffect(() => { + if (!open) return + + const focusTimer = window.setTimeout(() => { + inputRef.current?.focus() + }, 0) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeDialog() + } + } + + document.addEventListener('keydown', handleKeyDown) + + return () => { + window.clearTimeout(focusTimer) + document.removeEventListener('keydown', handleKeyDown) + } + }, [open, closeDialog]) + + useEffect(() => { + if (!open || !normalizedQuery || !onSearchTasks) { + return + } + + let cancelled = false + const queryText = query.trim() + + const searchTimer = window.setTimeout(() => { + setRemoteSearchState(previous => ({ + query: normalizedQuery, + items: previous.query === normalizedQuery ? previous.items : [], + loading: true, + })) + void onSearchTasks(queryText) + .then(result => { + if (cancelled) return + setRemoteSearchState({ + query: normalizedQuery, + items: buildRemoteSearchItems(projects, result.items), + loading: false, + }) + }) + .catch(() => { + if (cancelled) return + setRemoteSearchState({ + query: normalizedQuery, + items: [], + loading: false, + }) + }) + }, 200) + + return () => { + cancelled = true + window.clearTimeout(searchTimer) + } + }, [normalizedQuery, onSearchTasks, open, projects, query]) + + useEffect(() => { + if (!open || !normalizedQuery || onSearchTasks || !onSearchTaskDetail) return + + const missingItems = searchItems.filter( + item => + detailTextByTaskId[item.taskId] === undefined && + !loadingDetailTaskIds.has(item.taskId), + ) + if (missingItems.length === 0) return + + let cancelled = false + const taskIds = missingItems.map(item => item.taskId) + queueMicrotask(() => { + if (cancelled) return + setLoadingDetailTaskIds(previous => new Set([...previous, ...taskIds])) + }) + + void Promise.all( + missingItems.map(async item => { + try { + const detail = await onSearchTaskDetail(item.taskId) + return [item.taskId, getTaskDetailSearchText(detail)] as const + } catch { + return [item.taskId, ''] as const + } + }), + ).then(results => { + if (cancelled) return + + setDetailTextByTaskId(previous => { + const next = { ...previous } + results.forEach(([taskId, text]) => { + next[taskId] = text + }) + return next + }) + setLoadingDetailTaskIds(previous => { + const next = new Set(previous) + taskIds.forEach(taskId => next.delete(taskId)) + return next + }) + }) + + return () => { + cancelled = true + } + }, [ + detailTextByTaskId, + loadingDetailTaskIds, + normalizedQuery, + onSearchTasks, + onSearchTaskDetail, + open, + searchItems, + ]) + + if (!open) return null + + const handleOpenItem = (item: SearchDialogItem) => { + onOpenTask(item.taskId, item.projectId ?? 0) + closeDialog() + } + + return createPortal( +
{ + if (event.target === event.currentTarget) { + closeDialog() + } + }} + > +
+
+ setQuery(event.target.value)} + placeholder={t('workbench.search_conversations', '搜索对话')} + className="h-8 w-full bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted" + /> +
+ +
+
+ {t('workbench.recent_conversations', '近期对话')} +
+ {filteredItems.length > 0 ? ( +
+ {filteredItems.map(item => ( + + ))} +
+ ) : hasPendingSearch ? ( +
+ {t('workbench.searching_conversations', '正在搜索对话...')} +
+ ) : ( +
+ {t('workbench.search_no_results', '没有匹配的对话')} +
+ )} +
+
+
, + document.body, + ) +} diff --git a/wework/src/components/layout/DesktopSidebar.tsx b/wework/src/components/layout/DesktopSidebar.tsx index 4ddfbfb66fd..6c4f85e0536 100644 --- a/wework/src/components/layout/DesktopSidebar.tsx +++ b/wework/src/components/layout/DesktopSidebar.tsx @@ -28,9 +28,12 @@ import type { ProjectTask, ProjectWithTasks, Task, + TaskDetail, + TaskListResponse, User as UserProfile, } from '@/types/api' import { DesktopSettingsMenu } from './DesktopSettingsMenu' +import { DesktopSearchDialog } from './DesktopSearchDialog' import { useResizableSidebar } from './useResizableSidebar' interface DesktopSidebarProps { @@ -49,6 +52,8 @@ interface DesktopSidebarProps { onSelectProject: (projectId: number) => void onStartNewProjectChat: (projectId: number) => void onOpenTask: (taskId: number, projectId?: number) => void + onSearchTasks?: (query: string) => Promise + onSearchTaskDetail?: (taskId: number) => Promise onRememberExecutionDevice?: (deviceId: string) => void onOpenPlugins: () => void onRefreshDevices?: () => Promise @@ -496,6 +501,8 @@ export function DesktopSidebar({ onSelectProject, onStartNewProjectChat, onOpenTask, + onSearchTasks, + onSearchTaskDetail, onRememberExecutionDevice, onOpenPlugins, onRefreshDevices, @@ -522,12 +529,28 @@ export function DesktopSidebar({ const [renamingTask, setRenamingTask] = useState<{ id: number; title: string } | null>(null) const [projectsExpanded, setProjectsExpanded] = useState(true) const [chatsExpanded, setChatsExpanded] = useState(true) + const [searchDialogOpen, setSearchDialogOpen] = useState(false) const [expandedProjectIds, setExpandedProjectIds] = useState>(new Set()) const [expandedTaskListIds, setExpandedTaskListIds] = useState>(new Set()) const sortedRecentTasks = useMemo( () => sortTasksByTime(recentTasks).filter(task => !task.project_id), [recentTasks] ) + const currentProjectWithTask = useMemo( + () => + currentTaskId + ? projects.find(project => + project.tasks?.some(task => task.task_id === currentTaskId), + ) + : undefined, + [currentTaskId, projects], + ) + const currentProjectTaskIndex = useMemo(() => { + if (!currentProjectWithTask || !currentTaskId) return -1 + return sortProjectTasks(currentProjectWithTask.tasks).findIndex( + task => task.task_id === currentTaskId, + ) + }, [currentProjectWithTask, currentTaskId]) const handleToggleProject = (projectId: number) => { const shouldExpand = !expandedProjectIds.has(projectId) @@ -585,6 +608,54 @@ export function DesktopSidebar({ } }, [settingsMenuOpen]) + useEffect(() => { + if (!currentTaskId) return + + const timer = window.setTimeout(() => { + if (currentProjectWithTask) { + setProjectsExpanded(true) + setExpandedProjectIds(previous => { + if (previous.has(currentProjectWithTask.id)) return previous + return new Set([...previous, currentProjectWithTask.id]) + }) + if (currentProjectTaskIndex >= INITIAL_PROJECT_CHAT_COUNT) { + setExpandedTaskListIds(previous => { + if (previous.has(currentProjectWithTask.id)) return previous + return new Set([...previous, currentProjectWithTask.id]) + }) + } + return + } + + if (sortedRecentTasks.some(task => task.id === currentTaskId)) { + setChatsExpanded(true) + } + }, 0) + + return () => window.clearTimeout(timer) + }, [ + currentProjectTaskIndex, + currentProjectWithTask, + currentTaskId, + sortedRecentTasks, + ]) + + useEffect(() => { + if (!currentTaskId) return + + const taskRow = + document.querySelector(`[data-testid="project-chat-row-${currentTaskId}"]`) ?? + document.querySelector(`[data-testid="history-task-row-${currentTaskId}"]`) + + taskRow?.scrollIntoView({ block: 'nearest' }) + }, [ + chatsExpanded, + currentTaskId, + expandedProjectIds, + expandedTaskListIds, + projectsExpanded, + ]) + return (