From 9430fb3e992c5237195fb44fe99385e568c402a9 Mon Sep 17 00:00:00 2001 From: GCWing Date: Fri, 13 Mar 2026 23:37:41 +0800 Subject: [PATCH] feat(web-ui): move side questions into an auxiliary panel Keep main and side threads visible together while improving session switching, source navigation, and chat/tooling affordances around BTW flows. --- .../src/app/components/NavPanel/MainNav.tsx | 2 +- .../sections/sessions/SessionsSection.scss | 131 +++++++- .../sections/sessions/SessionsSection.tsx | 174 ++++++++-- .../sections/workspaces/WorkspaceItem.tsx | 26 +- .../components/panels/base/FlexiblePanel.tsx | 17 + .../src/app/components/panels/base/types.ts | 1 + .../src/app/components/panels/base/utils.ts | 11 +- .../panels/content-canvas/ContentCanvas.tsx | 22 +- .../components/Markdown/Markdown.scss | 41 +++ .../components/Tooltip/Tooltip.tsx | 34 +- .../src/flow_chat/components/ChatInput.scss | 110 ++++--- .../src/flow_chat/components/ChatInput.tsx | 308 ++++++++++++------ .../flow_chat/components/ModelSelector.tsx | 190 +++++------ .../flow_chat/components/RichTextInput.scss | 9 + .../components/btw/BtwSessionPanel.scss | 149 +++++++++ .../components/btw/BtwSessionPanel.tsx | 290 +++++++++++++++++ .../components/modern/FlowChatContext.tsx | 3 +- .../components/modern/FlowChatHeader.scss | 44 ++- .../components/modern/FlowChatHeader.tsx | 90 ++++- .../modern/ModernFlowChatContainer.tsx | 48 ++- .../components/modern/UserMessageItem.tsx | 5 +- .../src/flow_chat/services/FlowChatManager.ts | 1 + .../src/flow_chat/services/openBtwSession.ts | 135 ++++++++ .../flow_chat/store/modernFlowChatStore.ts | 2 +- .../flow_chat/tool-cards/BtwMarkerCard.tsx | 17 +- .../tool-cards/FileOperationToolCard.scss | 62 ++-- .../tool-cards/FileOperationToolCard.tsx | 111 +++++-- src/web-ui/src/locales/en-US/flow-chat.json | 14 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 16 +- 29 files changed, 1668 insertions(+), 395 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss create mode 100644 src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx create mode 100644 src/web-ui/src/flow_chat/services/openBtwSession.ts diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index 1287dc76..2e4a967f 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -606,7 +606,7 @@ const MainNav: React.FC = ({ aria-expanded={workspaceMenuOpen} onClick={toggleWorkspaceMenu} > - + ) : undefined} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 3f87f100..668eea48 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -92,25 +92,45 @@ } &.is-child { - height: 24px; + min-height: 24px; font-size: 12px; - padding-left: calc(#{$size-gap-1} + 14px); + padding-left: calc(#{$size-gap-1} + 18px); position: relative; &::before { + content: ''; + position: absolute; + left: 10px; + top: 0; + width: 1px; + height: 50%; + background: color-mix(in srgb, var(--border-subtle) 88%, transparent); + opacity: 0.95; + } + + &::after { content: ''; position: absolute; left: 10px; top: 50%; - width: 10px; + width: 12px; height: 1px; - background: var(--border-subtle); - opacity: 0.9; + background: color-mix(in srgb, var(--border-subtle) 92%, transparent); + opacity: 0.95; transform: translateY(-50%); } .bitfun-nav-panel__inline-item-icon { - opacity: 0.55; + opacity: 0.72; + } + } + + &.is-btw-child { + background: color-mix(in srgb, var(--element-bg-soft) 38%, transparent); + + &:hover, + &.is-active { + background: color-mix(in srgb, var(--element-bg-soft) 74%, transparent); } } } @@ -136,13 +156,60 @@ } } + &__inline-item-main { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + } + &__inline-item-label { flex: 1; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + &__inline-item-btw-badge { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + height: 12px; + padding: 0 4px; + border: 1px solid color-mix(in srgb, var(--border-subtle) 85%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--element-bg-soft) 42%, transparent); + color: var(--color-text-muted); + font-size: 9px; + font-weight: 500; + letter-spacing: 0.02em; + opacity: 0.78; + } + + &__inline-item-tooltip { + display: flex; + flex-direction: column; + gap: 2px; + max-width: 260px; + } + + &__inline-item-tooltip-title { + font-size: 12px; + font-weight: 600; + color: var(--color-text-primary); + line-height: 1.4; + word-break: break-word; + } + + &__inline-item-tooltip-meta { + font-size: 11px; + color: var(--color-text-secondary); + line-height: 1.4; + word-break: break-word; + } + &__inline-item-actions { display: none; align-items: center; @@ -170,12 +237,60 @@ transition: color $motion-fast $easing-standard, background $motion-fast $easing-standard; - &:hover { + &:hover, + &.is-open { color: var(--color-text-primary); background: color-mix(in srgb, var(--color-text-primary) 10%, transparent); } + } + + &__inline-item-menu-popover { + position: fixed; + width: max-content; + min-width: 140px; + max-width: min(220px, calc(100vw - 24px)); + padding: $size-gap-1 0; + border-radius: $size-radius-base; + background: var(--color-bg-elevated, #1e1e22); + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + z-index: 10000; + transform-origin: top left; + animation: bitfun-footer-menu-in $motion-fast $easing-decelerate forwards; + } + + &__inline-item-menu-item { + display: flex; + align-items: center; + gap: $size-gap-2; + width: 100%; + min-height: 28px; + padding: 0 $size-gap-2; + border: none; + background: transparent; + color: var(--color-text-secondary); + font-size: $font-size-sm; + font-weight: 400; + cursor: pointer; + text-align: left; + white-space: nowrap; + transition: color $motion-fast $easing-standard, + background $motion-fast $easing-standard; + + svg { + flex-shrink: 0; + opacity: 0.7; + transition: opacity $motion-fast $easing-standard; + } + + &:hover:not(:disabled) { + color: var(--color-text-primary); + background: var(--element-bg-soft); + + svg { opacity: 1; } + } - &.delete:hover { + &.is-danger:hover:not(:disabled) { color: var(--color-error, #ef4444); background: color-mix(in srgb, var(--color-error, #ef4444) 10%, transparent); } diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 225732a1..cdddb272 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -6,17 +6,23 @@ */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Pencil, Trash2, Check, X, Bot, Code2, Users } from 'lucide-react'; +import { createPortal } from 'react-dom'; +import { Pencil, Trash2, Check, X, Bot, Code2, Users, MoreHorizontal } from 'lucide-react'; import { IconButton, Input, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { flowChatStore } from '../../../../../flow_chat/store/FlowChatStore'; import { flowChatManager } from '../../../../../flow_chat/services/FlowChatManager'; import type { FlowChatState, Session } from '../../../../../flow_chat/types/flow-chat'; import { useSceneStore } from '../../../../stores/sceneStore'; -import { useApp } from '../../../../hooks/useApp'; import type { SceneTabId } from '../../../SceneBar/types'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { createLogger } from '@/shared/utils/logger'; +import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; +import { + openBtwSessionInAuxPane, + openMainSession, + selectActiveBtwSessionTab, +} from '@/flow_chat/services/openBtwSession'; import './SessionsSection.scss'; const MAX_VISIBLE_SESSIONS = 8; @@ -50,17 +56,22 @@ const SessionsSection: React.FC = ({ isActiveWorkspace = true, }) => { const { t } = useI18n('common'); - const { switchLeftPanelTab } = useApp(); const { setActiveWorkspace } = useWorkspaceContext(); - const openScene = useSceneStore(s => s.openScene); const activeTabId = useSceneStore(s => s.activeTabId); + const activeBtwSessionTab = useAgentCanvasStore(state => selectActiveBtwSessionTab(state as any)); + const activeBtwSessionData = activeBtwSessionTab?.content.data as + | { childSessionId: string; parentSessionId: string; workspacePath?: string } + | undefined; const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState() ); const [editingSessionId, setEditingSessionId] = useState(null); const [editingTitle, setEditingTitle] = useState(''); const [showAll, setShowAll] = useState(false); + const [openMenuSessionId, setOpenMenuSessionId] = useState(null); + const [sessionMenuPosition, setSessionMenuPosition] = useState<{ top: number; left: number } | null>(null); const editInputRef = useRef(null); + const sessionMenuPopoverRef = useRef(null); useEffect(() => { const unsub = flowChatStore.subscribe(s => setFlowChatState(s)); @@ -78,6 +89,18 @@ const SessionsSection: React.FC = ({ setShowAll(false); }, [workspaceId, workspacePath, isActiveWorkspace]); + useEffect(() => { + if (!openMenuSessionId) return; + const handleOutside = (event: MouseEvent) => { + if (!sessionMenuPopoverRef.current?.contains(event.target as Node)) { + setOpenMenuSessionId(null); + setSessionMenuPosition(null); + } + }; + document.addEventListener('mousedown', handleOutside); + return () => document.removeEventListener('mousedown', handleOutside); + }, [openMenuSessionId]); + const sessions = useMemo( () => Array.from(flowChatState.sessions.values()) @@ -154,14 +177,40 @@ const SessionsSection: React.FC = ({ const handleSwitch = useCallback( async (sessionId: string) => { if (editingSessionId) return; - openScene('session'); - switchLeftPanelTab('sessions'); - if (sessionId === activeSessionId) return; try { - if (workspaceId && !isActiveWorkspace) { - await setActiveWorkspace(workspaceId); + const session = flowChatStore.getState().sessions.get(sessionId); + const parentSessionId = session?.btwOrigin?.parentSessionId || session?.parentSessionId; + const activateWorkspace = workspaceId && !isActiveWorkspace + ? async (targetWorkspaceId: string) => { + await setActiveWorkspace(targetWorkspaceId); + } + : undefined; + + if (session?.sessionKind === 'btw' && parentSessionId) { + await openMainSession(parentSessionId, { + workspaceId, + activateWorkspace, + }); + openBtwSessionInAuxPane({ + childSessionId: sessionId, + parentSessionId, + workspacePath: session.workspacePath, + }); + return; + } + + if (sessionId === activeSessionId) { + await openMainSession(sessionId, { + workspaceId, + activateWorkspace, + }); + return; } - await flowChatManager.switchChatSession(sessionId); + + await openMainSession(sessionId, { + workspaceId, + activateWorkspace, + }); window.dispatchEvent( new CustomEvent('flowchat:switch-session', { detail: { sessionId } }) ); @@ -169,7 +218,7 @@ const SessionsSection: React.FC = ({ log.error('Failed to switch session', err); } }, - [activeSessionId, editingSessionId, isActiveWorkspace, openScene, setActiveWorkspace, switchLeftPanelTab, workspaceId] + [activeSessionId, editingSessionId, isActiveWorkspace, setActiveWorkspace, workspaceId] ); const resolveSessionTitle = useCallback( @@ -190,6 +239,28 @@ const SessionsSection: React.FC = ({ [t] ); + const handleMenuOpen = useCallback( + (e: React.MouseEvent, sessionId: string) => { + e.stopPropagation(); + if (openMenuSessionId === sessionId) { + setOpenMenuSessionId(null); + setSessionMenuPosition(null); + return; + } + const btn = e.currentTarget as HTMLElement; + const rect = btn.getBoundingClientRect(); + const viewportPadding = 8; + const estimatedWidth = 160; + const maxLeft = window.innerWidth - estimatedWidth - viewportPadding; + setSessionMenuPosition({ + top: Math.max(viewportPadding, rect.bottom + 4), + left: Math.max(viewportPadding, Math.min(rect.left, maxLeft)), + }); + setOpenMenuSessionId(sessionId); + }, + [openMenuSessionId] + ); + const handleDelete = useCallback( async (e: React.MouseEvent, sessionId: string) => { e.stopPropagation(); @@ -250,20 +321,37 @@ const SessionsSection: React.FC = ({ ) : ( visibleItems.map(({ session, level }) => { const isEditing = editingSessionId === session.sessionId; + const isBtwChild = level === 1 && session.sessionKind === 'btw'; const sessionModeKey = resolveSessionModeType(session); const sessionTitle = resolveSessionTitle(session); + const parentSessionId = session.btwOrigin?.parentSessionId || session.parentSessionId; + const parentSession = parentSessionId ? flowChatState.sessions.get(parentSessionId) : undefined; + const parentTitle = parentSession ? resolveSessionTitle(parentSession) : ''; + const parentTurnIndex = session.btwOrigin?.parentTurnIndex; + const tooltipContent = isBtwChild ? ( +
+
{sessionTitle}
+
+ {`来自 ${parentTitle || '父会话'}${parentTurnIndex ? ` · 第 ${parentTurnIndex} 轮` : ''}`} +
+
+ ) : sessionTitle; const SessionIcon = sessionModeKey === 'cowork' ? Users : sessionModeKey === 'claw' ? Bot : Code2; + const isRowActive = activeBtwSessionData?.childSessionId + ? session.sessionId === activeBtwSessionData.childSessionId + : activeTabId === AGENT_SCENE && session.sessionId === activeSessionId; const row = (
= ({
) : ( <> - {sessionTitle} + + {sessionTitle} + {isBtwChild ? ( + btw + ) : null} +
- handleStartEdit(e, session)} - tooltip={t('nav.sessions.rename')} - tooltipPlacement="top" +
+ {openMenuSessionId === session.sessionId && sessionMenuPosition && createPortal( +
+ + +
, + document.body + )} )} ); - return isEditing ? row : ( - + return isEditing || openMenuSessionId !== null ? row : ( + {row} ); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index b0475a63..6cfcc576 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -310,18 +310,20 @@ const WorkspaceItem: React.FC = ({ workspace, isActive, isSi {t('nav.workspaces.actions.newSession')} - + {workspace.workspaceKind !== WorkspaceKind.Assistant && ( + + )} + + + )}
= ({ ); } + if (slashCommandState.kind === 'all') { + const items = getSlashPickerItems(); + return ( +
+
+ {t('chatInput.quickAction', { defaultValue: 'Commands' })} + {t('chatInput.selectHint')} +
+
+ {items.length > 0 ? ( + items.map((item, index) => ( +
item.kind === 'mode' ? selectSlashCommandMode(item.id) : selectSlashCommandAction(item.id)} + onMouseEnter={() => setSlashCommandState(prev => ({ ...prev, selectedIndex: index }))} + > + + {item.kind === 'mode' ? `/${item.id}` : item.command} + + + {item.kind === 'mode' ? item.name : item.label} + + {item.kind === 'mode' && item.id === modeState.current && {t('chatInput.current')}} +
+ )) + ) : ( +
+ {t('chatInput.noMatchingCommand', { defaultValue: 'No matching command' })} +
+ )} +
+
+ ); + } + if (!canSwitchModes) return null; const filteredModes = getFilteredModes(); @@ -1462,7 +1572,7 @@ export const ChatInput: React.FC = ({
{tokenUsage.current > 0 && ( diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 1a58518a..55bad67a 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -31,6 +31,9 @@ interface ModelSelectorProps { interface ModelInfo { id: string; + /** User-defined configuration name (AIModelConfig.name). */ + configName: string; + /** Actual model identifier (AIModelConfig.model_name). */ modelName: string; providerName: string; provider: string; @@ -147,6 +150,7 @@ export const ModelSelector: React.FC = ({ if (modelId === 'auto') { return { id: 'auto', + configName: t('modelSelector.autoModel'), modelName: t('modelSelector.autoModel'), providerName: t('modelSelector.autoModelDesc'), provider: 'auto', @@ -161,7 +165,8 @@ export const ModelSelector: React.FC = ({ if (!model) return null; return { - id: modelId, // Keep 'primary' or 'fast' + id: modelId, + configName: modelId === 'primary' ? t('modelSelector.primaryModel') : t('modelSelector.fastModel'), modelName: model.model_name, providerName: getProviderDisplayName(model), provider: model.provider, @@ -176,6 +181,7 @@ export const ModelSelector: React.FC = ({ return { id: model.id || '', + configName: model.name, modelName: model.model_name, providerName: getProviderDisplayName(model), provider: model.provider, @@ -195,6 +201,7 @@ export const ModelSelector: React.FC = ({ }) .map(m => ({ id: m.id || '', + configName: m.name, modelName: m.model_name, providerName: getProviderDisplayName(m), provider: m.provider, @@ -241,7 +248,7 @@ export const ModelSelector: React.FC = ({ ref={dropdownRef} className={`bitfun-model-selector ${className}`} > - +
-
handleSelectModel('auto')} - > -
-
- {t('modelSelector.autoModelDesc')} - {t('modelSelector.autoModel')} + +
handleSelectModel('auto')} + > +
+ {t('modelSelector.autoModel')}
+ {currentModelId === 'auto' && ( + + )}
- {currentModelId === 'auto' && ( - - )} -
- -
handleSelectModel('primary')} - > -
-
- {t('modelSelector.primaryModel')} - {(() => { - const model = allModels.find(m => m.id === defaultModels.primary); - if (!model) { - return ( - - {t('modelSelector.modelNotConfigured')} - - ); - } - - return ( -
- {model.model_name} - - {buildModelMetaText({ - providerName: getProviderDisplayName(model), - contextWindow: model.context_window, - reasoningEffort: model.reasoning_effort, - })} - -
- ); - })()} -
-
- {currentModelId === 'primary' && ( - - )} -
- -
handleSelectModel('fast')} - > -
-
- {t('modelSelector.fastModel')} - {(() => { - const model = allModels.find(m => m.id === defaultModels.fast); - if (!model) { - return ( - - {t('modelSelector.modelNotConfigured')} - - ); - } - - return ( -
- {model.model_name} - - {buildModelMetaText({ - providerName: getProviderDisplayName(model), - contextWindow: model.context_window, - reasoningEffort: model.reasoning_effort, - })} - -
- ); - })()} -
-
- {currentModelId === 'fast' && ( - - )} -
+ + + {(() => { + const primaryModel = allModels.find(m => m.id === defaultModels.primary); + const primaryTooltip = primaryModel + ? `${primaryModel.name}(${primaryModel.model_name})· ${buildModelMetaText({ providerName: getProviderDisplayName(primaryModel), contextWindow: primaryModel.context_window, reasoningEffort: primaryModel.reasoning_effort })}` + : t('modelSelector.modelNotConfigured'); + return ( + +
handleSelectModel('primary')} + > +
+ {t('modelSelector.primaryModel')} +
+ {currentModelId === 'primary' && ( + + )} +
+
+ ); + })()} + + {(() => { + const fastModel = allModels.find(m => m.id === defaultModels.fast); + const fastTooltip = fastModel + ? `${fastModel.name}(${fastModel.model_name})· ${buildModelMetaText({ providerName: getProviderDisplayName(fastModel), contextWindow: fastModel.context_window, reasoningEffort: fastModel.reasoning_effort })}` + : t('modelSelector.modelNotConfigured'); + return ( + +
handleSelectModel('fast')} + > +
+ {t('modelSelector.fastModel')} +
+ {currentModelId === 'fast' && ( + + )} +
+
+ ); + })()}
@@ -368,26 +344,24 @@ export const ModelSelector: React.FC = ({ const isSelected = currentModelId === model.id; return ( -
handleSelectModel(model.id)} - > -
- - {model.modelName} - {model.enableThinking && ( - - )} - - - {buildModelMetaText(model)} - + +
handleSelectModel(model.id)} + > +
+ + {model.configName} + {model.enableThinking && ( + + )} + +
+ {isSelected && ( + + )}
- {isSelected && ( - - )} -
+ ); })}
diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.scss b/src/web-ui/src/flow_chat/components/RichTextInput.scss index 44cecc6c..fd168767 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.scss +++ b/src/web-ui/src/flow_chat/components/RichTextInput.scss @@ -43,6 +43,15 @@ } +// Improve placeholder contrast in light theme. +:root[data-theme="light"] .rich-text-input, +:root[data-theme-type="light"] .rich-text-input, +.light .rich-text-input { + &:empty::before { + color: var(--color-text-secondary, #6b7280); + } +} + // Placeholder styles .rich-text-placeholder { diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss new file mode 100644 index 00000000..2ebbce19 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss @@ -0,0 +1,149 @@ +/** + * BTW session panel styles. + * Align the shell with TaskDetailPanel while reusing FlowChat message renderers. + */ + +.btw-session-panel { + display: flex; + flex-direction: column; + height: 100%; + background: var(--color-bg-flowchat); + color: var(--color-text-primary); + + &--empty { + .btw-session-panel__empty-state { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--color-text-muted); + font-size: 13px; + } + } + + &__header { + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 16px; + background: var(--color-bg-secondary); + border-bottom: 1px dashed var(--border-base); + flex-shrink: 0; + min-height: 36px; + } + + &__header-left { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + } + + &__header-title-wrap { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + max-width: 60%; + } + + &__header-right { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + } + + &__badge { + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 500; + color: var(--color-text-secondary); + background: rgba(255, 255, 255, 0.08); + flex-shrink: 0; + } + + &__title { + font-size: 12px; + font-weight: 500; + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + text-align: center; + } + + &__meta { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--color-text-muted); + white-space: nowrap; + max-width: 240px; + } + + &__meta-label { + color: var(--color-text-muted); + } + + &__meta-title { + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__origin-button { + flex-shrink: 0; + color: var(--color-text-secondary); + + &:not(:disabled):hover { + background: color-mix(in srgb, var(--element-bg-soft) 82%, transparent); + color: var(--color-text-primary); + } + } + + &__body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 12px 16px; + display: block; + + &::after { + content: ''; + display: block; + height: 140px; + min-height: 140px; + width: 100%; + pointer-events: none; + } + + .virtual-item-wrapper { + width: 100%; + display: block; + } + } + + &__empty-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 120px; + color: var(--color-text-muted); + font-size: 13px; + text-align: center; + } +} diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx new file mode 100644 index 00000000..80a00daf --- /dev/null +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -0,0 +1,290 @@ +import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import path from 'path-browserify'; +import { MessageSquareQuote, Link2, CornerUpLeft } from 'lucide-react'; +import { FlowChatContext } from '../modern/FlowChatContext'; +import { VirtualItemRenderer } from '../modern/VirtualItemRenderer'; +import { ProcessingIndicator } from '../modern/ProcessingIndicator'; +import { flowChatStore } from '../../store/FlowChatStore'; +import type { FlowChatConfig, FlowChatState, Session } from '../../types/flow-chat'; +import { sessionToVirtualItems } from '../../store/modernFlowChatStore'; +import { fileTabManager } from '@/shared/services/FileTabManager'; +import { createTab } from '@/shared/utils/tabUtils'; +import { IconButton, type LineRange } from '@/component-library'; +import { globalEventBus } from '@/infrastructure/event-bus'; +import './BtwSessionPanel.scss'; + +export interface BtwSessionPanelProps { + childSessionId?: string; + parentSessionId?: string; + workspacePath?: string; +} + +const PANEL_CONFIG: FlowChatConfig = { + enableMarkdown: true, + autoScroll: true, + showTimestamps: false, + maxHistoryRounds: 50, + enableVirtualScroll: false, + theme: 'dark', +}; + +const resolveSessionTitle = (session?: Session | null, fallback = 'Side thread') => + session?.title?.trim() || fallback; + +export const BtwSessionPanel: React.FC = ({ + childSessionId, + parentSessionId, + workspacePath, +}) => { + const { t } = useTranslation('flow-chat'); + const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState()); + const scrollContainerRef = useRef(null); + const shouldAutoScrollRef = useRef(true); + + useEffect(() => { + const unsubscribe = flowChatStore.subscribe(setFlowChatState); + return unsubscribe; + }, []); + + const childSession = childSessionId ? flowChatState.sessions.get(childSessionId) : undefined; + const parentSession = parentSessionId ? flowChatState.sessions.get(parentSessionId) : undefined; + const virtualItems = useMemo(() => sessionToVirtualItems(childSession ?? null), [childSession]); + + // Load history for historical sessions that have not yet had their turns loaded. + const isLoadingRef = useRef(false); + useEffect(() => { + if (!childSessionId || !childSession) return; + if (!childSession.isHistorical) return; + if (isLoadingRef.current) return; + + const path = workspacePath ?? childSession.workspacePath; + if (!path) return; + + isLoadingRef.current = true; + flowChatStore.loadSessionHistory(childSessionId, path).finally(() => { + isLoadingRef.current = false; + }); + }, [childSessionId, childSession, workspacePath]); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const handleWheel = (e: WheelEvent) => { + if (e.deltaY < 0) { + shouldAutoScrollRef.current = false; + } else if (e.deltaY > 0) { + const { scrollTop, scrollHeight, clientHeight } = container; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + if (distanceFromBottom < 100) { + shouldAutoScrollRef.current = true; + } + } + }; + + container.addEventListener('wheel', handleWheel, { passive: true }); + return () => container.removeEventListener('wheel', handleWheel); + }, []); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container || !shouldAutoScrollRef.current) return; + requestAnimationFrame(() => { + container.scrollTop = container.scrollHeight; + }); + }, [virtualItems]); + + const handleFileViewRequest = useCallback(( + filePath: string, + fileName: string, + lineRange?: LineRange + ) => { + let absoluteFilePath = filePath; + const isWindowsAbsolutePath = /^[A-Za-z]:[\\/]/.test(filePath); + + if (!isWindowsAbsolutePath && !path.isAbsolute(filePath) && workspacePath) { + absoluteFilePath = path.join(workspacePath, filePath); + } + + fileTabManager.openFile({ + filePath: absoluteFilePath, + fileName, + workspacePath, + jumpToRange: lineRange, + mode: 'agent', + }); + }, [workspacePath]); + + const handleTabOpen = useCallback((tabInfo: any) => { + if (!tabInfo?.type) return; + createTab({ + type: tabInfo.type, + title: tabInfo.title || 'New Tab', + data: tabInfo.data, + metadata: tabInfo.metadata, + checkDuplicate: !!tabInfo.metadata?.duplicateCheckKey, + duplicateCheckKey: tabInfo.metadata?.duplicateCheckKey, + replaceExisting: false, + mode: 'agent', + }); + }, []); + + const contextValue = useMemo(() => ({ + onFileViewRequest: handleFileViewRequest, + onTabOpen: handleTabOpen, + sessionId: childSessionId, + activeSessionOverride: childSession ?? null, + config: PANEL_CONFIG, + }), [childSession, childSessionId, handleFileViewRequest, handleTabOpen]); + + const lastDialogTurn = childSession?.dialogTurns[childSession.dialogTurns.length - 1]; + const lastModelRound = lastDialogTurn?.modelRounds[lastDialogTurn.modelRounds.length - 1]; + const lastItem = lastModelRound?.items[lastModelRound.items.length - 1]; + const lastItemContent = lastItem && 'content' in lastItem ? String((lastItem as any).content || '') : ''; + const isTurnProcessing = lastDialogTurn?.status === 'processing' || lastDialogTurn?.status === 'image_analyzing'; + const [isContentGrowing, setIsContentGrowing] = useState(true); + const lastContentRef = useRef(lastItemContent); + const contentTimeoutRef = useRef | null>(null); + + useEffect(() => { + if (lastItemContent !== lastContentRef.current) { + lastContentRef.current = lastItemContent; + setIsContentGrowing(true); + if (contentTimeoutRef.current) clearTimeout(contentTimeoutRef.current); + contentTimeoutRef.current = setTimeout(() => { + setIsContentGrowing(false); + }, 500); + } + + return () => { + if (contentTimeoutRef.current) { + clearTimeout(contentTimeoutRef.current); + } + }; + }, [lastItemContent]); + + useEffect(() => { + if (!isTurnProcessing) { + setIsContentGrowing(false); + } + }, [isTurnProcessing]); + + const showProcessingIndicator = useMemo(() => { + if (!isTurnProcessing) return false; + if (!lastItem) return true; + + if (lastItem.type === 'text' || lastItem.type === 'thinking') { + const hasContent = 'content' in lastItem && Boolean((lastItem as any).content); + if (hasContent && isContentGrowing) { + return false; + } + } + + if (lastItem.type === 'tool') { + const toolStatus = (lastItem as any).status; + if (toolStatus === 'running' || toolStatus === 'streaming' || toolStatus === 'preparing') { + return false; + } + } + + return true; + }, [isTurnProcessing, lastItem, isContentGrowing]); + + const btwOrigin = childSession?.btwOrigin; + const parentLabel = resolveSessionTitle(parentSession, t('btw.parent')); + const backTooltip = btwOrigin?.parentTurnIndex + ? t('flowChatHeader.btwBackTooltipWithTurn', { + title: parentLabel, + turn: btwOrigin.parentTurnIndex, + defaultValue: `Go back to the source session: ${parentLabel} (Turn ${btwOrigin.parentTurnIndex})`, + }) + : t('flowChatHeader.btwBackTooltipWithoutTurn', { + title: parentLabel, + defaultValue: `Go back to the source session: ${parentLabel}`, + }); + + const handleFocusOriginTurn = useCallback(() => { + const resolvedParentSessionId = btwOrigin?.parentSessionId || parentSessionId; + if (!resolvedParentSessionId) return; + + const requestId = btwOrigin?.requestId; + const itemId = requestId ? `btw_marker_${requestId}` : undefined; + + globalEventBus.emit( + 'flowchat:focus-item', + { + sessionId: resolvedParentSessionId, + turnIndex: btwOrigin?.parentTurnIndex, + itemId, + }, + 'BtwSessionPanel' + ); + }, [btwOrigin, parentSessionId]); + + if (!childSessionId || !childSession) { + return ( +
+ + {t('btw.threadLabel')} +
+ ); + } + + return ( + +
+
+
+ {t('btw.shortLabel')} +
+
+ {resolveSessionTitle(childSession, t('btw.threadLabel'))} +
+
+
+ {t('btw.origin')} + + {resolveSessionTitle(parentSession, t('btw.parent'))} +
+ {!!(btwOrigin?.parentSessionId || parentSessionId) && ( + + + + )} +
+
+ +
+ {virtualItems.length === 0 ? ( +
{t('session.empty')}
+ ) : ( + <> + {virtualItems.map((item, index) => ( + + ))} + + + )} +
+
+
+ ); +}; + +BtwSessionPanel.displayName = 'BtwSessionPanel'; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx index 7cf475a5..f73bd284 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx @@ -4,7 +4,7 @@ */ import { createContext, useContext } from 'react'; -import type { FlowChatConfig } from '../../types/flow-chat'; +import type { FlowChatConfig, Session } from '../../types/flow-chat'; import type { LineRange } from '@/component-library'; export interface FlowChatContextValue { @@ -20,6 +20,7 @@ export interface FlowChatContextValue { // Session info sessionId?: string; + activeSessionOverride?: Session | null; // Config config?: FlowChatConfig; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss index 6fbcf7be..5d5ca732 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss @@ -47,16 +47,49 @@ -webkit-mask-image: linear-gradient(to bottom, black 0%, transparent 100%); } - &__turn-info { - font-size: $font-size-sm; - font-weight: $font-weight-medium; - color: var(--color-text-secondary); - white-space: nowrap; + &__btw-back { + flex: 0 0 auto; + + &:not(:disabled):hover { + background: color-mix(in srgb, var(--element-bg-soft) 82%, transparent); + } + } + + &__btw-create { + flex: 0 0 auto; + + &:not(:disabled):hover { + background: color-mix(in srgb, var(--element-bg-soft) 82%, transparent); + } } // ==================== Center message ==================== &__message { flex: 1; + min-width: 0; + padding: 0 $size-gap-3; + display: flex; + align-items: center; + justify-content: center; + gap: $size-gap-2; + overflow: hidden; + } + + &__turn-badge { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + max-width: 100%; + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--element-bg-soft) 82%, transparent); + color: var(--color-text-secondary); + font-size: $font-size-xs; + line-height: 1; + white-space: nowrap; + } + + &__message-text { min-width: 0; font-size: $font-size-sm; color: var(--color-text-primary); @@ -64,7 +97,6 @@ overflow: hidden; text-overflow: ellipsis; text-align: center; - padding: 0 $size-gap-3; } // ==================== Actions ==================== diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx index ef3cfe9d..e35bae24 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -5,13 +5,17 @@ */ import React from 'react'; -import { Tooltip } from '@/component-library'; +import { CornerUpLeft, MessageSquarePlus } from 'lucide-react'; +import { Tooltip, IconButton } from '@/component-library'; +import { useTranslation } from 'react-i18next'; +import { globalEventBus } from '@/infrastructure/event-bus'; import { SessionFilesBadge } from './SessionFilesBadge'; +import type { Session } from '../../types/flow-chat'; import './FlowChatHeader.scss'; export interface FlowChatHeaderProps { - /** Current visible turn index (1-based). */ - currentTurnIndex: number; + /** Current turn index. */ + currentTurn: number; /** Total turns. */ totalTurns: number; /** Current user message. */ @@ -20,14 +24,25 @@ export interface FlowChatHeaderProps { visible: boolean; /** Session ID. */ sessionId?: string; + /** BTW child-session origin metadata. */ + btwOrigin?: Session['btwOrigin'] | null; + /** BTW parent session title. */ + btwParentTitle?: string; + /** Creates a new BTW thread from the current session. */ + onCreateBtwSession?: () => void; } export const FlowChatHeader: React.FC = ({ - currentTurnIndex, + currentTurn, totalTurns, currentUserMessage, visible, sessionId, + btwOrigin, + btwParentTitle = '', + onCreateBtwSession, }) => { + const { t } = useTranslation('flow-chat'); + if (!visible || totalTurns === 0) { return null; } @@ -36,6 +51,36 @@ export const FlowChatHeader: React.FC = ({ const truncatedMessage = currentUserMessage.length > 50 ? currentUserMessage.slice(0, 50) + '...' : currentUserMessage; + const parentLabel = btwParentTitle || t('btw.parent', { defaultValue: 'parent session' }); + const backTooltip = btwOrigin?.parentTurnIndex + ? t('flowChatHeader.btwBackTooltipWithTurn', { + title: parentLabel, + turn: btwOrigin.parentTurnIndex, + defaultValue: `Go back to the source session: ${parentLabel} (Turn ${btwOrigin.parentTurnIndex})`, + }) + : t('flowChatHeader.btwBackTooltipWithoutTurn', { + title: parentLabel, + defaultValue: `Go back to the source session: ${parentLabel}`, + }); + const createBtwTooltip = t('flowChatHeader.btwCreateTooltip', { + defaultValue: 'Start a quick side question', + }); + const turnBadgeLabel = t('flowChatHeader.turnBadge', { + current: currentTurn, + defaultValue: `Turn ${currentTurn}`, + }); + + const handleBackToParent = () => { + const parentId = btwOrigin?.parentSessionId; + if (!parentId) return; + const requestId = btwOrigin?.requestId; + const itemId = requestId ? `btw_marker_${requestId}` : undefined; + globalEventBus.emit('flowchat:focus-item', { + sessionId: parentId, + turnIndex: btwOrigin?.parentTurnIndex, + itemId, + }, 'FlowChatHeader'); + }; return (
@@ -45,14 +90,43 @@ export const FlowChatHeader: React.FC = ({
- {truncatedMessage} + + {turnBadgeLabel} + + + {truncatedMessage} +
- - {currentTurnIndex} / {totalTurns} - + {!!btwOrigin?.parentSessionId && ( + + + + )} + {onCreateBtwSession && ( + + + + )}
); diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index be6f597b..dd037289 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -15,7 +15,7 @@ import { startAutoSync } from '../../services/storeSync'; import { useModernFlowChatStore } from '../../store/modernFlowChatStore'; import { globalEventBus } from '../../../infrastructure/event-bus'; import { getElementText, copyTextToClipboard } from '../../../shared/utils/textSelection'; -import type { FlowChatConfig, FlowToolItem, DialogTurn, ModelRound, FlowItem } from '../../types/flow-chat'; +import type { FlowChatConfig, FlowToolItem, DialogTurn, ModelRound, FlowItem, Session } from '../../types/flow-chat'; import { notificationService } from '../../../shared/notification-system'; import { agentAPI } from '@/infrastructure/api'; import { fileTabManager } from '@/shared/services/FileTabManager'; @@ -54,6 +54,9 @@ export const ModernFlowChatContainer: React.FC = ( const visibleTurnInfo = useVisibleTurnInfo(); const virtualListRef = useRef(null); const { workspacePath } = useWorkspaceContext(); + const isBtwSession = activeSession?.sessionKind === 'btw'; + const [btwOrigin, setBtwOrigin] = useState(null); + const [btwParentTitle, setBtwParentTitle] = useState(''); // Explore group collapse state (key: groupId, true = user-expanded). const [exploreGroupStates, setExploreGroupStates] = useState>(new Map()); @@ -95,6 +98,36 @@ export const ModernFlowChatContainer: React.FC = ( unsubscribe(); }; }, []); + + useEffect(() => { + const syncBtwState = (state = flowChatStore.getState()) => { + const currentSessionId = activeSession?.sessionId; + if (!currentSessionId) { + setBtwOrigin(null); + setBtwParentTitle(''); + return; + } + + const session = state.sessions.get(currentSessionId); + if (!session) { + setBtwOrigin(null); + setBtwParentTitle(''); + return; + } + + const nextOrigin = (session.btwOrigin || + (session.sessionKind === 'btw' && session.parentSessionId ? { parentSessionId: session.parentSessionId } : null)) as Session['btwOrigin'] | null; + const parentId = nextOrigin?.parentSessionId || session.parentSessionId; + const parent = parentId ? state.sessions.get(parentId) : undefined; + + setBtwOrigin(nextOrigin); + setBtwParentTitle(parent?.title || ''); + }; + + syncBtwState(); + const unsubscribe = flowChatStore.subscribe(syncBtwState); + return unsubscribe; + }, [activeSession?.sessionId]); useEffect(() => { const unlisten = agentAPI.onSessionTitleGenerated((event) => { @@ -420,6 +453,7 @@ export const ModernFlowChatContainer: React.FC = ( onToolConfirm: handleToolConfirm, onToolReject: handleToolReject, sessionId: activeSession?.sessionId, + activeSessionOverride: activeSession, config: { enableMarkdown: true, autoScroll: true, @@ -447,16 +481,26 @@ export const ModernFlowChatContainer: React.FC = ( handleExpandAllInTurn, handleCollapseGroup, ]); + + const handleCreateBtwSession = useCallback(() => { + if (!activeSession?.sessionId) return; + window.dispatchEvent(new CustomEvent('fill-chat-input', { + detail: { message: '/btw ' } + })); + }, [activeSession?.sessionId]); return (
0} sessionId={activeSession?.sessionId} + btwOrigin={btwOrigin} + btwParentTitle={btwParentTitle} + onCreateBtwSession={activeSession?.sessionId && !isBtwSession ? handleCreateBtwSession : undefined} />
diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index c6bf137e..1a713843 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -27,8 +27,9 @@ interface UserMessageItemProps { export const UserMessageItem = React.memo( ({ message, turnId }) => { const { t } = useTranslation('flow-chat'); - const { config, sessionId } = useFlowChatContext(); - const activeSession = useActiveSession(); + const { config, sessionId, activeSessionOverride } = useFlowChatContext(); + const activeSessionFromStore = useActiveSession(); + const activeSession = activeSessionOverride ?? activeSessionFromStore; const [copied, setCopied] = useState(false); const [expanded, setExpanded] = useState(false); const [hasOverflow, setHasOverflow] = useState(false); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 6154bcd1..65393c50 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -298,6 +298,7 @@ export class FlowChatManager { id: markerId, input: { requestId, + parentSessionId, childSessionId, title, }, diff --git a/src/web-ui/src/flow_chat/services/openBtwSession.ts b/src/web-ui/src/flow_chat/services/openBtwSession.ts new file mode 100644 index 00000000..597f817a --- /dev/null +++ b/src/web-ui/src/flow_chat/services/openBtwSession.ts @@ -0,0 +1,135 @@ +import { i18nService } from '@/infrastructure/i18n'; +import { appManager } from '@/app/services/AppManager'; +import { useSceneStore } from '@/app/stores/sceneStore'; +import { createTab } from '@/shared/utils/tabUtils'; +import type { PanelContent } from '@/app/components/panels/base/types'; +import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; +import type { CanvasTab } from '@/app/components/panels/content-canvas/types'; +import { flowChatStore } from '../store/FlowChatStore'; +import { flowChatManager } from './FlowChatManager'; + +export const BTW_SESSION_PANEL_TYPE = 'btw-session' as const; + +export interface BtwSessionPanelData { + childSessionId: string; + parentSessionId: string; + workspacePath?: string; +} + +export interface BtwSessionPanelMetadata { + duplicateCheckKey: string; + childSessionId: string; + parentSessionId: string; + contentRole: 'btw-session'; +} + +type AgentCanvasState = ReturnType; + +const getBtwSessionDuplicateKey = (childSessionId: string) => `btw-session-${childSessionId}`; + +const resolveBtwSessionTitle = (childSessionId: string): string => { + const session = flowChatStore.getState().sessions.get(childSessionId); + const title = session?.title?.trim(); + if (title) return title; + return i18nService.t('flow-chat:btw.threadLabel', { defaultValue: 'Side thread' }); +}; + +export const isBtwSessionPanelContent = (content: PanelContent | null | undefined): boolean => + content?.type === BTW_SESSION_PANEL_TYPE; + +export const buildBtwSessionPanelContent = ( + childSessionId: string, + parentSessionId: string, + workspacePath?: string +): PanelContent => ({ + type: BTW_SESSION_PANEL_TYPE, + title: resolveBtwSessionTitle(childSessionId), + data: { + childSessionId, + parentSessionId, + workspacePath, + } satisfies BtwSessionPanelData, + metadata: { + duplicateCheckKey: getBtwSessionDuplicateKey(childSessionId), + childSessionId, + parentSessionId, + contentRole: 'btw-session', + } satisfies BtwSessionPanelMetadata, +}); + +export const selectActiveAgentTab = (state: AgentCanvasState) => { + const activeGroup = state.activeGroupId === 'primary' + ? state.primaryGroup + : state.activeGroupId === 'secondary' + ? state.secondaryGroup + : state.tertiaryGroup; + const activeTabId = activeGroup.activeTabId; + if (!activeTabId) return null; + return activeGroup.tabs.find(tab => tab.id === activeTabId && !tab.isHidden) ?? null; +}; + +export const selectActiveBtwSessionTab = (state: AgentCanvasState): CanvasTab | null => { + const activeTab = selectActiveAgentTab(state); + if (!activeTab || !isBtwSessionPanelContent(activeTab.content)) { + return null; + } + + const data = activeTab.content.data as BtwSessionPanelData | undefined; + if (!data?.childSessionId || !data.parentSessionId) { + return null; + } + + return activeTab; +}; + +export async function openMainSession( + sessionId: string, + options?: { + workspaceId?: string; + activateWorkspace?: (workspaceId: string) => Promise | void; + } +): Promise { + useSceneStore.getState().openScene('session'); + appManager.updateLayout({ + leftPanelActiveTab: 'sessions', + leftPanelCollapsed: false, + }); + + if (options?.workspaceId && options.activateWorkspace) { + await options.activateWorkspace(options.workspaceId); + } + + if (flowChatStore.getState().activeSessionId === sessionId) { + return; + } + + await flowChatManager.switchChatSession(sessionId); +} + +export function openBtwSessionInAuxPane(params: { + childSessionId: string; + parentSessionId: string; + workspacePath?: string; + expand?: boolean; +}): void { + const content = buildBtwSessionPanelContent( + params.childSessionId, + params.parentSessionId, + params.workspacePath + ); + + if (params.expand !== false) { + window.dispatchEvent(new CustomEvent('expand-right-panel')); + } + + createTab({ + type: content.type, + title: content.title, + data: content.data, + metadata: content.metadata, + checkDuplicate: true, + duplicateCheckKey: content.metadata?.duplicateCheckKey, + replaceExisting: false, + mode: 'agent', + }); +} diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index f40f1041..60806c6e 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -119,7 +119,7 @@ let cachedVirtualItems: VirtualItem[] = []; * * Explore group merging: consecutive explore-only rounds merged into single explore-group VirtualItem */ -function sessionToVirtualItems(session: Session | null): VirtualItem[] { +export function sessionToVirtualItems(session: Session | null): VirtualItem[] { if (!session) { if (cachedSession !== null) { cachedSession = null; diff --git a/src/web-ui/src/flow_chat/tool-cards/BtwMarkerCard.tsx b/src/web-ui/src/flow_chat/tool-cards/BtwMarkerCard.tsx index 0e6b5117..3daaa431 100644 --- a/src/web-ui/src/flow_chat/tool-cards/BtwMarkerCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/BtwMarkerCard.tsx @@ -9,9 +9,9 @@ import { CornerDownRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; -import { flowChatManager } from '../services/FlowChatManager'; +import { openBtwSessionInAuxPane, openMainSession } from '../services/openBtwSession'; -export const BtwMarkerCard: React.FC = React.memo(({ toolItem }) => { +export const BtwMarkerCard: React.FC = React.memo(({ toolItem, sessionId }) => { const { t } = useTranslation('flow-chat'); const input = (toolItem.toolCall?.input || {}) as any; @@ -28,9 +28,18 @@ export const BtwMarkerCard: React.FC = React.memo(({ toolItem }) status="completed" isExpanded={false} clickable={clickable} - onClick={() => { + onClick={async () => { if (!childSessionId) return; - void flowChatManager.switchChatSession(childSessionId); + const parentSessionId = typeof input?.parentSessionId === 'string' && input.parentSessionId + ? input.parentSessionId + : sessionId; + if (!parentSessionId) return; + + await openMainSession(parentSessionId); + openBtwSessionInAuxPane({ + childSessionId, + parentSessionId, + }); }} header={ = ({ const { toolCall, toolResult, status, isParamsStreaming, partialParams } = toolItem; const [isErrorExpanded, setIsErrorExpanded] = useState(false); - const [isPreviewExpanded, setIsPreviewExpanded] = useState(false); + const [isPreviewExpanded, setIsPreviewExpanded] = useState(isParamsStreaming); const [operationDiffStats, setOperationDiffStats] = useState<{ additions: number; deletions: number } | null>(null); const prevIsParamsStreamingRef = useRef(isParamsStreaming); + const userCollapsedRef = useRef(false); useEffect(() => { const prevIsParamsStreaming = prevIsParamsStreamingRef.current; @@ -47,6 +50,7 @@ export const FileOperationToolCard: React.FC = ({ prevIsParamsStreamingRef.current = isParamsStreaming; if (isParamsStreaming) { + userCollapsedRef.current = false; setIsPreviewExpanded(true); } else { setIsPreviewExpanded(false); @@ -131,9 +135,30 @@ export const FileOperationToolCard: React.FC = ({ } }, [error, clearError, currentFilePath]); + const localDiffStats = useMemo(() => { + if (status !== 'completed' || isFailed) return null; + if (toolItem.toolName === 'Write' && contentPreview) { + const lines = contentPreview.split('\n'); + const count = lines[lines.length - 1] === '' ? lines.length - 1 : lines.length; + return { additions: count, deletions: 0 }; + } + if (toolItem.toolName === 'Edit' && (oldStringContent || newStringContent)) { + const changes = diffLines(oldStringContent, newStringContent); + let additions = 0; + let deletions = 0; + for (const change of changes) { + const lineCount = change.count ?? 0; + if (change.added) additions += lineCount; + else if (change.removed) deletions += lineCount; + } + return { additions, deletions }; + } + return null; + }, [toolItem.toolName, contentPreview, oldStringContent, newStringContent, status, isFailed]); + const currentFileDiffStats = useMemo(() => { - return operationDiffStats ?? { additions: 0, deletions: 0 }; - }, [operationDiffStats]); + return operationDiffStats ?? localDiffStats ?? { additions: 0, deletions: 0 }; + }, [operationDiffStats, localDiffStats]); useEffect(() => { if (!sessionId || !toolCall?.id || status !== 'completed' || isFailed) return; @@ -341,28 +366,27 @@ export const FileOperationToolCard: React.FC = ({ {t('toolCards.file.deletedLabel')} )} - {!isDeleteTool && !isParamsStreaming && !isFailed && !isLoading && (currentFileDiffStats.additions > 0 || currentFileDiffStats.deletions > 0) && ( - - {currentFileDiffStats.additions > 0 && ( - +{currentFileDiffStats.additions} - )} - {currentFileDiffStats.deletions > 0 && ( - -{currentFileDiffStats.deletions} - )} - - )} - - {!isFailed && (oldStringContent || newStringContent || contentPreview) && ( + {!isDeleteTool && !isParamsStreaming && !isFailed && !isLoading && ( + (currentFileDiffStats.additions > 0 || currentFileDiffStats.deletions > 0 || oldStringContent || newStringContent || contentPreview) + ) && ( )} @@ -503,6 +527,55 @@ export const FileOperationToolCard: React.FC = ({ const isDeleteTool = toolItem.toolName === 'Delete'; + const getDeleteStatusIcon = () => { + switch (status) { + case 'running': + case 'streaming': + case 'preparing': + return ; + case 'completed': + return ; + case 'pending': + case 'confirmed': + case 'pending_confirmation': + case 'analyzing': + default: + return ; + } + }; + + const renderDeleteContent = () => { + const baseLabel = `${t('toolCards.file.delete')}: ${fileName}`; + + if (status === 'completed') { + return baseLabel; + } + + if (status === 'error') { + return `${t('toolCards.file.delete')}${t('toolCards.file.failed')}: ${fileName}`; + } + + return baseLabel; + }; + + if (isDeleteTool) { + return ( + + } + /> + ); + } + return ( Tab for next placeholder, Shift+Tab for previous, Esc to exit", "templateProgress": "{{current}} / {{total}}", "wip": "WIP", + "sendTarget": "Send to", + "conversationTarget": "Conversation target", + "targetMain": "Main", + "targetBtw": "Side", + "sendingToMain": "Main session: {{title}}", + "sendingToBtw": "Side session: {{title}}", "modeDescriptions": { "agentic": "Full-featured AI assistant with access to all tools for comprehensive software development tasks", "Claw": "Personal assistant mode for dedicated assistant workspaces and everyday task support", @@ -257,6 +263,7 @@ }, "btw": { "title": "Side question", + "shortLabel": "Side", "hint": "Not saved · Uses current chat context · No tools", "placeholder": "Ask a quick question…", "ask": "Ask", @@ -270,13 +277,18 @@ "back": "Back", "backToParent": "Back to parent session", "empty": "Please provide a question after /btw", + "nestedDisabled": "A side question cannot start another side question", "error": "Failed", "close": "Close", "noSession": "No active session for /btw" }, "flowChatHeader": { "previousTurn": "Previous turn", - "nextTurn": "Next turn" + "nextTurn": "Next turn", + "turnBadge": "Turn {{current}}", + "btwBackTooltipWithTurn": "Go back to the source session: {{title}} (Turn {{turn}})", + "btwBackTooltipWithoutTurn": "Go back to the source session: {{title}}", + "btwCreateTooltip": "Start a quick side question" }, "sessionFilesBadge": { "files": "files", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index bf26d785..ab12bfe0 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -193,6 +193,12 @@ "templateHint": "按 Tab 切换到下一个占位符,Shift+Tab 返回上一个,Esc 退出编辑", "templateProgress": "{{current}} / {{total}}", "wip": "开发中", + "sendTarget": "发送到", + "conversationTarget": "对话目标", + "targetMain": "主会话", + "targetBtw": "当前侧问", + "sendingToMain": "主会话:{{title}}", + "sendingToBtw": "侧问会话:{{title}}", "modeDescriptions": { "agentic": "AI 主导执行,自动规划和完成编码任务,拥有完整的工具访问能力", "Claw": "个人助理模式:面向个人工作区和日常事务,使用独立的助理上下文", @@ -256,7 +262,8 @@ "close": "关闭" }, "btw": { - "title": "BTW", + "title": "侧问", + "shortLabel": "侧问", "hint": "不保存 · 使用当前对话上下文 · 无工具", "placeholder": "问个小问题…", "ask": "提问", @@ -270,13 +277,18 @@ "back": "返回", "backToParent": "返回父会话", "empty": "请在 /btw 后面写上问题", + "nestedDisabled": "侧问会话中不能继续新建侧问", "error": "失败", "close": "关闭", "noSession": "当前没有可用于 /btw 的会话" }, "flowChatHeader": { "previousTurn": "上一轮对话", - "nextTurn": "下一轮对话" + "nextTurn": "下一轮对话", + "turnBadge": "第 {{current}} 轮", + "btwBackTooltipWithTurn": "返回到来源会话:{{title}}(第 {{turn}} 轮)", + "btwBackTooltipWithoutTurn": "返回到来源会话:{{title}}", + "btwCreateTooltip": "快速发起一个侧问" }, "sessionFilesBadge": { "files": "文件",