diff --git a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss index 45ab9668..01918215 100644 --- a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss +++ b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss @@ -181,6 +181,15 @@ opacity: 0.88; } +// Default (no query): sessions column — label only, no rows; hint below label. +.bitfun-nav-search-dialog__session-hint { + padding: $size-gap-2 $size-gap-2 $size-gap-1 2px; + font-size: var(--font-size-xs); + line-height: 1.45; + color: var(--color-text-muted); + user-select: none; +} + // ── Result item ────────────────────────────────────────────────────────────── .bitfun-nav-search-dialog__item { @@ -259,7 +268,7 @@ border-radius: $size-radius-base; background: var(--element-bg-soft); box-shadow: none; - cursor: pointer; + cursor: text; color: var(--color-text-primary); font-size: var(--font-size-sm); font-weight: 500; diff --git a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx index 3daff42e..e22fe9ec 100644 --- a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx +++ b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx @@ -12,7 +12,9 @@ import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { findWorkspaceForSession } from '@/flow_chat/utils/workspaceScope'; import { openMainSession } from '@/flow_chat/services/openBtwSession'; import type { FlowChatState, Session } from '@/flow_chat/types/flow-chat'; +import type { SessionMetadata } from '@/shared/types/session-history'; import type { WorkspaceInfo } from '@/shared/types'; +import { sessionAPI } from '@/infrastructure/api'; import { WorkspaceKind } from '@/shared/types'; import './NavSearchDialog.scss'; @@ -36,6 +38,9 @@ const MAX_PER_GROUP = 20; const getTitle = (session: Session): string => session.title?.trim() || `Session ${session.sessionId.slice(0, 6)}`; +const sessionRecencyTime = (session: Session): number => + session.updatedAt ?? session.lastActiveAt ?? session.createdAt ?? 0; + const matchesQuery = (query: string, ...fields: (string | undefined | null)[]): boolean => { const q = query.toLowerCase(); return fields.some(f => f && f.toLowerCase().includes(q)); @@ -51,6 +56,10 @@ const NavSearchDialog: React.FC = ({ open, onClose }) => { const [query, setQuery] = useState(''); const [activeIndex, setActiveIndex] = useState(0); const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState()); + /** Persisted session rows for opened workspaces — filled when dialog opens (search filters client-side). */ + const [persistedOpenWorkspaceSessions, setPersistedOpenWorkspaceSessions] = useState< + Array<{ meta: SessionMetadata; workspace: WorkspaceInfo }> + >([]); const inputRef = useRef(null); const listRef = useRef(null); const cardRef = useRef(null); @@ -68,25 +77,66 @@ const NavSearchDialog: React.FC = ({ open, onClose }) => { } }, [open]); + useEffect(() => { + if (!open) { + setPersistedOpenWorkspaceSessions([]); + return; + } + let cancelled = false; + void (async () => { + try { + const rows: Array<{ meta: SessionMetadata; workspace: WorkspaceInfo }> = []; + for (const w of openedWorkspacesList) { + const list = await sessionAPI.listSessions( + w.rootPath, + w.connectionId ?? undefined, + w.sshHost ?? undefined + ); + for (const meta of list) { + rows.push({ meta, workspace: w }); + } + } + if (!cancelled) { + setPersistedOpenWorkspaceSessions(rows); + } + } catch { + if (!cancelled) { + setPersistedOpenWorkspaceSessions([]); + } + } + })(); + return () => { + cancelled = true; + }; + }, [open, openedWorkspacesList]); + const projectWorkspaces = useMemo( () => openedWorkspacesList.filter(w => w.workspaceKind !== WorkspaceKind.Assistant), [openedWorkspacesList] ); - const allSessions = useMemo((): Array<{ session: Session; workspace: WorkspaceInfo | undefined }> => { - const result: Array<{ session: Session; workspace: WorkspaceInfo | undefined }> = []; - const allWorkspaces = [...openedWorkspacesList]; + const openedWorkspaceIdSet = useMemo( + () => new Set(openedWorkspacesList.map(w => w.id)), + [openedWorkspacesList] + ); + + /** Sessions that resolve to an opened workspace (project + assistant rows in the nav). */ + const sessionsInOpenedWorkspaces = useMemo((): Array<{ session: Session; workspace: WorkspaceInfo }> => { + const result: Array<{ session: Session; workspace: WorkspaceInfo }> = []; for (const session of flowChatState.sessions.values()) { - const workspace = findWorkspaceForSession(session, allWorkspaces); - result.push({ session, workspace }); + const workspace = findWorkspaceForSession(session, openedWorkspacesList); + if (workspace && openedWorkspaceIdSet.has(workspace.id)) { + result.push({ session, workspace }); + } } - result.sort((a, b) => { - const aTime = a.session.updatedAt ?? a.session.createdAt ?? 0; - const bTime = b.session.updatedAt ?? b.session.createdAt ?? 0; - return bTime - aTime; - }); + result.sort((a, b) => sessionRecencyTime(b.session) - sessionRecencyTime(a.session)); return result; - }, [flowChatState.sessions, openedWorkspacesList]); + }, [flowChatState.sessions, openedWorkspacesList, openedWorkspaceIdSet]); + + const mainLineSessionsOpen = useMemo( + () => sessionsInOpenedWorkspaces.filter(({ session }) => !session.parentSessionId), + [sessionsInOpenedWorkspaces] + ); const results = useMemo((): SearchResultItem[] => { const items: SearchResultItem[] = []; @@ -118,21 +168,67 @@ const NavSearchDialog: React.FC = ({ open, onClose }) => { items.push({ kind: 'assistant', id: w.id, label: displayName, sublabel: w.description }); } - const filteredSessions = allSessions - .filter(({ session }) => !session.parentSessionId && matchesQuery(q, getTitle(session))) - .slice(0, MAX_PER_GROUP); - for (const { session, workspace } of filteredSessions) { - items.push({ - kind: 'session', - id: session.sessionId, - label: getTitle(session), - sublabel: workspace ? t('nav.search.sessionWorkspaceHint', { workspace: workspace.name }) : undefined, - workspaceId: workspace?.id, - }); + const storeMatches = mainLineSessionsOpen.filter(({ session }) => + matchesQuery(q, getTitle(session), session.sessionId) + ); + const storeIds = new Set(storeMatches.map(({ session }) => session.sessionId)); + + const diskMatches = persistedOpenWorkspaceSessions.filter(({ meta, workspace }) => { + if (!openedWorkspaceIdSet.has(workspace.id)) return false; + if (meta.customMetadata?.parentSessionId) return false; + const label = meta.sessionName?.trim() || `Session ${meta.sessionId.slice(0, 6)}`; + if (!matchesQuery(q, label, meta.sessionId)) return false; + return !storeIds.has(meta.sessionId); + }); + + const merged: Array<{ session: Session; workspace: WorkspaceInfo } | { disk: SessionMetadata; workspace: WorkspaceInfo }> = [ + ...storeMatches.map(({ session, workspace }) => ({ session, workspace })), + ...diskMatches.map(({ meta, workspace }) => ({ disk: meta, workspace })), + ]; + merged.sort((a, b) => { + const ta = + 'session' in a + ? sessionRecencyTime(a.session) + : a.disk.lastActiveAt ?? a.disk.createdAt ?? 0; + const tb = + 'session' in b + ? sessionRecencyTime(b.session) + : b.disk.lastActiveAt ?? b.disk.createdAt ?? 0; + return tb - ta; + }); + + for (const entry of merged.slice(0, MAX_PER_GROUP)) { + if ('session' in entry) { + const { session, workspace } = entry; + items.push({ + kind: 'session', + id: session.sessionId, + label: getTitle(session), + sublabel: t('nav.search.sessionWorkspaceHint', { workspace: workspace.name }), + workspaceId: workspace.id, + }); + } else { + const { disk: meta, workspace } = entry; + items.push({ + kind: 'session', + id: meta.sessionId, + label: meta.sessionName?.trim() || `Session ${meta.sessionId.slice(0, 6)}`, + sublabel: t('nav.search.sessionWorkspaceHint', { workspace: workspace.name }), + workspaceId: workspace.id, + }); + } } return items; - }, [query, projectWorkspaces, assistantWorkspacesList, allSessions, t]); + }, [ + query, + projectWorkspaces, + assistantWorkspacesList, + mainLineSessionsOpen, + persistedOpenWorkspaceSessions, + openedWorkspaceIdSet, + t, + ]); useEffect(() => { setActiveIndex(0); @@ -190,6 +286,8 @@ const NavSearchDialog: React.FC = ({ open, onClose }) => { const workspaceItems = results.filter(r => r.kind === 'workspace'); const assistantItems = results.filter(r => r.kind === 'assistant'); const sessionItems = results.filter(r => r.kind === 'session'); + const queryTrimmed = query.trim(); + const showDefaultSessionColumn = !queryTrimmed; let globalIndex = 0; const renderGroup = ( @@ -245,13 +343,22 @@ const NavSearchDialog: React.FC = ({ open, onClose }) => { />
- {results.length === 0 ? ( + {results.length === 0 && !showDefaultSessionColumn ? (
{t('nav.search.empty')}
) : ( <> {renderGroup(t('nav.search.groupWorkspaces'), workspaceItems, () => )} {renderGroup(t('nav.search.groupAssistants'), assistantItems, () => )} - {renderGroup(t('nav.search.groupSessions'), sessionItems, () => )} + {showDefaultSessionColumn ? ( +
+
{t('nav.search.groupSessions')}
+
+ {t('nav.search.sessionSearchHintDefault')} +
+
+ ) : ( + renderGroup(t('nav.search.groupSessions'), sessionItems, () => ) + )} )}
diff --git a/src/web-ui/src/flow_chat/utils/workspaceScope.ts b/src/web-ui/src/flow_chat/utils/workspaceScope.ts index b835474b..5528c054 100644 --- a/src/web-ui/src/flow_chat/utils/workspaceScope.ts +++ b/src/web-ui/src/flow_chat/utils/workspaceScope.ts @@ -19,9 +19,10 @@ type WorkspaceScope = Pick> = ['compact', 'small', 'default', 'medium', 'large']; @@ -32,20 +32,26 @@ export function FontPreferencePanel() { } }, [preference.flowChat.mode, setFlowChatFont]); + /** Baseline px currently applied in the UI (preset level or custom). */ + const getEffectiveUiBasePx = useCallback((): number => { + if (level === 'custom') { + const n = parseInt(customInput, 10); + if (!isNaN(n) && n >= 12 && n <= 20) return n; + return customPx ?? 14; + } + return PRESET_UI_BASE_PX[level]; + }, [level, customInput, customPx]); + const handleLevelClick = useCallback(async (l: FontSizeLevel) => { if (l === 'custom') { - const px = parseInt(customInput, 10); - if (isNaN(px) || px < 12 || px > 20) { - await setUiSize('custom', 14); - setCustomInput('14'); - } else { - await setUiSize('custom', px); - } + const px = getEffectiveUiBasePx(); + setCustomInput(String(px)); + await setUiSize('custom', px); } else { await setUiSize(l); } setCustomError(null); - }, [customInput, setUiSize]); + }, [getEffectiveUiBasePx, setUiSize]); const handleCustomInputChange = (e: React.ChangeEvent) => { const raw = e.target.value; diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 0d29d14f..195445f0 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -107,6 +107,7 @@ "groupAssistants": "Assistants", "groupSessions": "All Sessions", "empty": "No results found", + "sessionSearchHintDefault": "Type a keyword to search sessions in open workspaces", "sessionWorkspaceHint": "in {{workspace}}" }, "displayModes": { diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index bbff5299..595fd4a4 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -107,6 +107,7 @@ "groupAssistants": "助理", "groupSessions": "所有会话", "empty": "未找到相关结果", + "sessionSearchHintDefault": "输入关键词,搜索已打开工作区内的会话", "sessionWorkspaceHint": "位于 {{workspace}}" }, "displayModes": {