From d55df6758d0293ef9fbfe8346505a9fc29f1d5b6 Mon Sep 17 00:00:00 2001 From: GCWing Date: Wed, 4 Nov 2026 11:23:26 +0800 Subject: [PATCH] feat(flow-chat): browse recent sessions in the context picker --- src/apps/desktop/src/api/session_api.rs | 34 +++- .../generated/remote-surface-registry.json | 4 +- .../src/remote_surface/capabilities.rs | 5 + .../components/ChatContextPicker.tsx | 152 ++++++++++-------- .../ChatContextPickerOverlay.test.tsx | 127 +++++++++++++++ .../utils/chatInputKeyOwnership.test.ts | 10 +- .../flow_chat/utils/chatInputKeyOwnership.ts | 2 + .../api/generated/remoteSurface.ts | 4 +- .../api/service-api/SessionAPI.ts | 4 +- .../peer-device/PeerConnectionManager.ts | 4 + src/web-ui/src/locales/en-US/flow-chat.json | 6 + src/web-ui/src/locales/zh-CN/flow-chat.json | 6 + src/web-ui/src/locales/zh-TW/flow-chat.json | 6 + 13 files changed, 280 insertions(+), 84 deletions(-) diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index 283de8185a..86c884318a 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -153,7 +153,10 @@ pub struct ExportSessionTranscriptRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchReferenceableSessionsRequest { - pub query: String, + /// Omit the query to browse recent sessions. An explicit blank query is + /// still an empty search for legacy callers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub query: Option, #[serde(default = "default_session_reference_search_limit")] pub limit: usize, } @@ -369,7 +372,7 @@ pub async fn search_session_content( }) } -/// Search lightweight persisted metadata across open local and SSH +/// Browse or search lightweight persisted metadata across open local and SSH /// workspaces. This deliberately never loads dialog turns or generates a /// transcript; that work happens only when the selected message is dispatched. #[tauri::command] @@ -378,8 +381,8 @@ pub async fn search_referenceable_sessions( runtime: State<'_, DesktopRuntimeContext>, app_state: State<'_, AppState>, ) -> Result, String> { - let query = request.query.trim().to_lowercase(); - if query.is_empty() { + let query = request.query.map(|query| query.trim().to_lowercase()); + if query.as_deref() == Some("") { return Ok(Vec::new()); } let limit = request.limit.clamp(1, 30); @@ -425,7 +428,9 @@ pub async fn search_referenceable_sessions( || scheduler.as_ref().is_some_and(|scheduler| { scheduler.is_session_busy_or_queued(&session.session_id) }) - || !session.session_name.to_lowercase().contains(&query) + || query + .as_ref() + .is_some_and(|query| !session.session_name.to_lowercase().contains(query)) { continue; } @@ -925,7 +930,24 @@ pub async fn delete_all_archived_sessions( #[cfg(test)] mod catalog_request_tests { - use super::ListPersistedSessionsPageRequest; + use super::{ListPersistedSessionsPageRequest, SearchReferenceableSessionsRequest}; + + #[test] + fn session_reference_request_distinguishes_browsing_from_legacy_search() { + for query in ["Docs", ""] { + let legacy = serde_json::json!({"query": query, "limit": 30}); + let request: SearchReferenceableSessionsRequest = + serde_json::from_value(legacy.clone()).unwrap(); + assert_eq!(request.query.as_deref(), Some(query)); + assert_eq!(serde_json::to_value(request).unwrap(), legacy); + } + + let browse = serde_json::json!({"limit": 30}); + let request: SearchReferenceableSessionsRequest = + serde_json::from_value(browse.clone()).unwrap(); + assert!(request.query.is_none()); + assert_eq!(serde_json::to_value(request).unwrap(), browse); + } #[test] fn session_catalog_request_preserves_legacy_payload_and_optional_query() { diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index 7e5d6b929a..cd53c46de0 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:7dadb93da46481aa", + "digest": "fnv1a64:ad57aa872484f2a8", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -49,6 +49,7 @@ "workspace_html_preview_v1", "whole_session_copy_v1", "session_trajectory_v1", + "session_reference_browse_v1", "user_question_response", "user_question_interaction_v1", "ssh_none_auth_v1", @@ -95,6 +96,7 @@ "workspace_html_preview_v1", "whole_session_copy_v1", "session_trajectory_v1", + "session_reference_browse_v1", "user_question_response", "user_question_interaction_v1", "ssh_none_auth_v1" diff --git a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs index e91b88c807..7869338741 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs @@ -103,6 +103,8 @@ pub enum PeerHostCapability { WholeSessionCopyV1, /// Bounded session trajectory summaries and host-side body search. SessionTrajectoryV1, + /// `search_referenceable_sessions` accepts an omitted query to browse. + SessionReferenceBrowseV1, /// `submit_user_answers` is implemented for Runtime-owned questions. UserQuestionResponse, /// First human interaction disables the unattended question timeout. @@ -159,6 +161,7 @@ impl PeerHostCapability { Self::WorkspaceHtmlPreviewV1, Self::WholeSessionCopyV1, Self::SessionTrajectoryV1, + Self::SessionReferenceBrowseV1, Self::UserQuestionResponse, Self::UserQuestionInteractionV1, Self::SshNoneAuthV1, @@ -207,6 +210,7 @@ impl PeerHostCapability { Self::WorkspaceIdReferencesV1 => "workspace_id_references_v1", Self::WorkspaceHtmlPreviewV1 => "workspace_html_preview_v1", Self::SessionTrajectoryV1 => "session_trajectory_v1", + Self::SessionReferenceBrowseV1 => "session_reference_browse_v1", Self::WholeSessionCopyV1 => "whole_session_copy_v1", Self::UserQuestionResponse => "user_question_response", Self::UserQuestionInteractionV1 => "user_question_interaction_v1", @@ -264,6 +268,7 @@ const DESKTOP_CAPABILITIES: &[PeerHostCapability] = &[ PeerHostCapability::WorkspaceHtmlPreviewV1, PeerHostCapability::WholeSessionCopyV1, PeerHostCapability::SessionTrajectoryV1, + PeerHostCapability::SessionReferenceBrowseV1, PeerHostCapability::UserQuestionResponse, PeerHostCapability::UserQuestionInteractionV1, PeerHostCapability::SshNoneAuthV1, diff --git a/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx b/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx index f3ce6ad847..5b15c01f17 100644 --- a/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx +++ b/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx @@ -1,10 +1,10 @@ import { subscribeOverlayInteraction, createOverlayPortal, Disclosure } from '@openbitfun/ui'; /** * Unified chat context picker. - * The source level exposes files, skills, MCP, and images; typing searches providers together. + * The source level exposes files, sessions, skills, MCP, and images; typing searches providers together. */ -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef, useSyncExternalStore } from 'react'; import { Icon, IconButton, @@ -18,6 +18,9 @@ import { import { useTranslation } from 'react-i18next'; import { File, Loader2, MessageCircle, Plug, RotateCcw } from 'lucide-react'; import { sessionAPI, workspaceAPI } from '@/infrastructure/api'; +import { getActiveSurfaceScope, isLocalSurface, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { isTauriRuntime } from '@/infrastructure/runtime/environment'; import { externalSourcesAPI, type WorkspaceReferenceEntry, @@ -47,6 +50,12 @@ import './ChatContextPicker.scss'; const log = createLogger('ChatContextPicker'); const CONTEXT_PICKER_SEARCH_DEBOUNCE_MS = 300; const CONTEXT_PICKER_MAX_RESULTS = 30; +const SOURCE_LABELS = { + files: 'chatInput.boostAddContext', + skills: 'chatInput.boostSkills', + mcp: 'contextPicker.mcp.label', + sessions: 'contextPicker.sessions.label', +} as const; export interface ChatContextPickerProps { isOpen: boolean; @@ -91,14 +100,14 @@ export interface ContextPickerSkill { export type ChatContextPickerEntryView = 'sources' | 'files'; -type ContextPickerView = ChatContextPickerEntryView | 'skills' | 'mcp'; +type ContextPickerView = ChatContextPickerEntryView | keyof typeof SOURCE_LABELS; type ContextPickerItem = | { kind: 'file'; item: FileItem } | { kind: 'session'; item: SessionReferenceCandidate } | { kind: 'skill'; item: ContextPickerSkill } | { kind: 'mcp'; item: ContextPickerMcpItem } - | { kind: 'source'; id: 'files' | 'skills' | 'mcp' } + | { kind: 'source'; id: keyof typeof SOURCE_LABELS } | { kind: 'action'; id: 'add-image' | 'retry-skills' }; function mergeFileSearchResults( @@ -154,15 +163,21 @@ export const ChatContextPicker: React.FC = ({ onAddImage, }) => { const { t } = useTranslation('flow-chat'); + const surfaceScope = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceScope); + const peer = usePeerDeviceModeOptional(); + const canBrowseSessions = isLocalSurface(surfaceScope.surfaceId) + ? isTauriRuntime() + : peer?.peerMode.active === true + && peer.peerMode.deviceId === surfaceScope.surfaceId + && peer.currentPeerCapabilities?.sessionReferenceBrowseV1 === true; const skillScanFailures = skillDiagnostics.filter(item => !item.unsupportedField); const unsupportedSkills = skillDiagnostics.filter(item => item.unsupportedField); const [results, setResults] = useState([]); - const [sessionResults, setSessionResults] = useState([]); + const [sessionLookup, setSessionLookup] = useState<{ key: string; items: SessionReferenceCandidate[] | null }>(); const [workspaceReferences, setWorkspaceReferences] = useState([]); const [currentFiles, setCurrentFiles] = useState([]); const [isDirectoryLoading, setIsDirectoryLoading] = useState(false); const [isFileSearchLoading, setIsFileSearchLoading] = useState(false); - const [isSessionLoading, setIsSessionLoading] = useState(false); const [directoryLoadError, setDirectoryLoadError] = useState(false); const [fileSearchError, setFileSearchError] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); @@ -173,12 +188,10 @@ export const ChatContextPicker: React.FC = ({ const fallbackAnchorRef = useRef(null); const fileAbortControllerRef = useRef(null); const fileSearchDebounceTimerRef = useRef(null); - const sessionSearchDebounceTimerRef = useRef(null); const selectedItemHistoryRef = useRef([]); const targetSelectedPathRef = useRef(null); const directoryLoadRequestIdRef = useRef(0); const fileSearchRequestIdRef = useRef(0); - const sessionSearchRequestIdRef = useRef(0); const workspaceReferenceRequestIdRef = useRef(0); const skipNextPathLoadRef = useRef(false); @@ -265,16 +278,15 @@ export const ChatContextPicker: React.FC = ({ }, [entryView, pathHistory, view]); useEffect(() => { + setView(entryView); if (!isOpen) return; // Suppress the path effect once while the picker resets from its previous // view. Direct file entry is loaded explicitly below. skipNextPathLoadRef.current = true; - setView(entryView); setCurrentPath(''); setPathHistory([]); setCurrentFiles([]); setResults([]); - setSessionResults([]); setDirectoryLoadError(false); setFileSearchError(false); setSelectedIndex(0); @@ -409,47 +421,44 @@ export const ChatContextPicker: React.FC = ({ }; }, [isOpen, searchQuery, searchFiles]); + const trimmedQuery = searchQuery.trim(); + const isSearchMode = trimmedQuery.length > 0; + const isSessionBrowse = view === 'sessions' && !isSearchMode; + const sessionLookupKey = isOpen && (isSearchMode || (isSessionBrowse && canBrowseSessions)) + ? surfaceScope.key(surfaceScope.epoch, trimmedQuery) + : null; + const currentSessionLookup = sessionLookup?.key === sessionLookupKey ? sessionLookup : undefined; + const isSessionLoading = sessionLookupKey !== null && !currentSessionLookup; + const sessionBrowseFailed = isSessionBrowse && currentSessionLookup?.items === null; + useEffect(() => { - if (sessionSearchDebounceTimerRef.current !== null) { - window.clearTimeout(sessionSearchDebounceTimerRef.current); - sessionSearchDebounceTimerRef.current = null; - } - const query = searchQuery.trim(); - if (!isOpen || !query) { - sessionSearchRequestIdRef.current += 1; - setSessionResults([]); - setIsSessionLoading(false); - return; - } + setSessionLookup(undefined); + if (sessionLookupKey === null) return; - const requestId = ++sessionSearchRequestIdRef.current; - setIsSessionLoading(true); - sessionSearchDebounceTimerRef.current = window.setTimeout(() => { - sessionSearchDebounceTimerRef.current = null; - void sessionAPI.searchReferenceableSessions(query, CONTEXT_PICKER_MAX_RESULTS) - .then((items) => { - if (requestId === sessionSearchRequestIdRef.current) { - setSessionResults(items.filter(item => item.sessionId !== excludeSessionId)); + let cancelled = false; + const loadSessions = () => { + if (!surfaceScope.isCurrent()) return; + void sessionAPI.searchReferenceableSessions(trimmedQuery || undefined, CONTEXT_PICKER_MAX_RESULTS) + .then(items => { + if (!cancelled && surfaceScope.isCurrent()) { + setSessionLookup({ key: sessionLookupKey, items }); } }) - .catch((error) => { - log.error('Context picker session search failed', error); - if (requestId === sessionSearchRequestIdRef.current) setSessionResults([]); - }) - .finally(() => { - if (requestId === sessionSearchRequestIdRef.current) setIsSessionLoading(false); + .catch(error => { + if (cancelled || !surfaceScope.isCurrent()) return; + log.error('Context picker session lookup failed', error); + setSessionLookup({ key: sessionLookupKey, items: null }); }); - }, CONTEXT_PICKER_SEARCH_DEBOUNCE_MS); + }; + const timer = isSearchMode ? window.setTimeout(loadSessions, CONTEXT_PICKER_SEARCH_DEBOUNCE_MS) : null; + if (timer === null) loadSessions(); return () => { - if (sessionSearchDebounceTimerRef.current !== null) { - window.clearTimeout(sessionSearchDebounceTimerRef.current); - sessionSearchDebounceTimerRef.current = null; - } + cancelled = true; + if (timer !== null) window.clearTimeout(timer); }; - }, [excludeSessionId, isOpen, searchQuery]); + }, [isSearchMode, sessionLookupKey, surfaceScope, trimmedQuery]); - const normalizedSearchQuery = searchQuery.trim().toLocaleLowerCase(); - const isSearchMode = normalizedSearchQuery.length > 0; + const normalizedSearchQuery = trimmedQuery.toLocaleLowerCase(); const isFileLoading = isSearchMode ? isFileSearchLoading : view === 'files' && isDirectoryLoading; @@ -488,6 +497,7 @@ export const ChatContextPicker: React.FC = ({ { kind: 'source', id: 'files' }, ...(onSelectSkill ? [{ kind: 'source' as const, id: 'skills' as const }] : []), ...(onSelectMcp ? [{ kind: 'source' as const, id: 'mcp' as const }] : []), + { kind: 'source', id: 'sessions' }, ...(onAddImage ? [{ kind: 'action' as const, id: 'add-image' as const }] : []), ], [onAddImage, onSelectSkill, onSelectMcp]); const mcpItems = useMemo(() => ( @@ -502,6 +512,12 @@ export const ChatContextPicker: React.FC = ({ } return filteredSkills.map(item => ({ kind: 'skill', item })); }, [filteredSkills, onRetrySkills, skillsLoadFailed, skillsLoading]); + const sessionItems = useMemo( + () => (currentSessionLookup?.items ?? []) + .filter(item => item.sessionId !== excludeSessionId) + .map(item => ({ kind: 'session', item })), + [currentSessionLookup, excludeSessionId], + ); const displayItems = useMemo(() => { if (isSearchMode) { const addImageMatches = Boolean(onAddImage) @@ -512,7 +528,7 @@ export const ChatContextPicker: React.FC = ({ ...(onSelectSkill ? filteredSkills.map(item => ({ kind: 'skill' as const, item })) : []), - ...sessionResults.map(item => ({ kind: 'session' as const, item })), + ...sessionItems, ...mcpItems, ...(onSelectSkill && skillsLoadFailed && onRetrySkills ? [{ kind: 'action' as const, id: 'retry-skills' as const }] @@ -525,6 +541,7 @@ export const ChatContextPicker: React.FC = ({ if (entryView === 'sources' && view === 'sources') return sourceItems; if (view === 'skills') return skillItems; if (view === 'mcp') return mcpItems; + if (view === 'sessions') return sessionItems; return [ ...currentFiles.map(item => ({ kind: 'file' as const, item })), ...(currentPath ? [] : referenceItems.map(item => ({ kind: 'file' as const, item }))), @@ -541,7 +558,7 @@ export const ChatContextPicker: React.FC = ({ referenceItems, results, sourceItems, - sessionResults, + sessionItems, entryView, skillItems, mcpItems, @@ -568,13 +585,9 @@ export const ChatContextPicker: React.FC = ({ return directorySegments.join('/'); }, [currentPath, getRelativePath, t, workspacePath]); const currentDirectoryTailStart = Math.max(0, currentDirectoryPath.lastIndexOf('/')); - const currentViewLabel = view === 'skills' - ? t('chatInput.boostSkills') - : view === 'mcp' - ? t('contextPicker.mcp.label') - : view === 'sources' - ? t('contextPicker.menuTitle') - : currentDirectoryPath; + const currentViewLabel = view === 'sources' ? t('contextPicker.menuTitle') + : view === 'files' ? currentDirectoryPath + : t(SOURCE_LABELS[view]); const canNavigateBack = !isSearchMode && ( pathHistory.length > 0 || (entryView === 'sources' && view !== 'sources') ); @@ -591,7 +604,6 @@ export const ChatContextPicker: React.FC = ({ useEffect(() => () => { if (fileSearchDebounceTimerRef.current !== null) window.clearTimeout(fileSearchDebounceTimerRef.current); - if (sessionSearchDebounceTimerRef.current !== null) window.clearTimeout(sessionSearchDebounceTimerRef.current); fileAbortControllerRef.current?.abort(); }, []); @@ -634,6 +646,7 @@ export const ChatContextPicker: React.FC = ({ const timestamp = Date.now(); if (selection.kind === 'session') { + if (!surfaceScope.isCurrent()) return; const session = selection.item; onSelectContext({ id: `session-reference-${timestamp}-${Math.random().toString(36).slice(2, 9)}`, @@ -668,7 +681,7 @@ export const ChatContextPicker: React.FC = ({ timestamp, }); onClose(); - }, [onAddImage, onClose, onRetrySkills, onSelectContext, onSelectSkill, onSelectMcp, openSource]); + }, [onAddImage, onClose, onRetrySkills, onSelectContext, onSelectSkill, onSelectMcp, openSource, surfaceScope]); const handleItemClick = useCallback((selection: ContextPickerItem, event: React.MouseEvent) => { // Browsing keeps its own affordance: the trailing chevron opens a directory, @@ -698,21 +711,18 @@ export const ChatContextPicker: React.FC = ({ break; } case 'ArrowRight': { - const selected = displayItems[selectedIndex]; - if (isSearchMode || !selected) break; - if (selected.kind !== 'source' - && !(selected.kind === 'file' && selected.item.isDirectory)) break; event.preventDefault(); event.stopPropagation(); + const selected = displayItems[selectedIndex]; + if (isSearchMode || !selected) break; if (selected.kind === 'source') openSource(selected.id); - if (selected.kind === 'file') enterDirectory(selected.item); + if (selected.kind === 'file' && selected.item.isDirectory) enterDirectory(selected.item); break; } case 'ArrowLeft': { - if (!canNavigateBack) break; event.preventDefault(); event.stopPropagation(); - goBack(); + if (canNavigateBack) goBack(); break; } case 'Enter': @@ -781,7 +791,9 @@ export const ChatContextPicker: React.FC = ({ ? t('chatInput.boostSkillsEmpty') : view === 'mcp' ? mcpStatus ?? t('contextPicker.mcp.empty') - : t('contextPicker.emptyDirectory'); + : isSessionBrowse + ? t(canBrowseSessions ? 'contextPicker.sessions.empty' : 'contextPicker.sessions.unsupportedHost') + : t('contextPicker.emptyDirectory'); const picker = (
= ({ data-openbitfun-part="root" data-openbitfun-state={[ isLoading && 'loading', - (fileLoadError || (showMcpStatus && mcpLoadFailed)) && 'error', + (fileLoadError || sessionBrowseFailed || (showMcpStatus && mcpLoadFailed)) && 'error', ].filter(Boolean).join(' ') || undefined} data-openbitfun-placement={isOverlay ? overlayLayout?.placement ?? 'top' : undefined} ref={containerRef} @@ -863,6 +875,10 @@ export const ChatContextPicker: React.FC = ({ ? 'contextPicker.searchUnavailable' : 'contextPicker.browseUnavailable')} + ) : sessionBrowseFailed ? ( + + {t('contextPicker.sessions.loadFailed')} + ) : displayItems.length === 0 && isLoading ? ( {t('contextPicker.loading')} ) : displayItems.length === 0 ? ( @@ -887,11 +903,7 @@ export const ChatContextPicker: React.FC = ({ ? mcp.key : `file-${file?.referenceStableKey || file?.path}`; const label = selection.kind === 'source' - ? t(selection.id === 'files' - ? 'chatInput.boostAddContext' - : selection.id === 'mcp' - ? 'contextPicker.mcp.label' - : 'chatInput.boostSkills') + ? t(SOURCE_LABELS[selection.id]) : selection.kind === 'action' ? t(selection.id === 'add-image' ? 'input.addImage' @@ -920,7 +932,9 @@ export const ChatContextPicker: React.FC = ({ leading={selection.kind === 'source' ? selection.id === 'mcp' ?