diff --git a/src/ui/src/components/chat/toolViews/McpBashExecView.tsx b/src/ui/src/components/chat/toolViews/McpBashExecView.tsx index aeac71e8..227f20ea 100644 --- a/src/ui/src/components/chat/toolViews/McpBashExecView.tsx +++ b/src/ui/src/components/chat/toolViews/McpBashExecView.tsx @@ -205,6 +205,33 @@ function isActiveBashStatus(status?: string | null) { return ['running', 'calling', 'pending', 'queued', 'starting', 'terminating'].includes(normalized) } +function stableProgressKey(value: BashProgress | null) { + if (!value) return '' + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +function progressEquals(left: BashProgress | null, right: BashProgress | null) { + return left === right || stableProgressKey(left) === stableProgressKey(right) +} + +function buildLiveStateKey(args: { + status: BashSessionStatus | null + exitCode: number | null + stopReason: string + progress: BashProgress | null +}) { + return [ + args.status ?? '', + args.exitCode == null ? '' : String(args.exitCode), + args.stopReason, + stableProgressKey(args.progress), + ].join('\u0001') +} + const filterMarkerLines = (log: string) => { if (!log) return '' return log @@ -496,6 +523,11 @@ function McpBashExecSessionView({ const hasSnapshotRef = useRef(false) const initialLoadAttemptedRef = useRef(false) const snapshotTimerRef = useRef(null) + const lastLiveStateKeyRef = useRef('') + + const setProgressIfChanged = useCallback((nextProgress: BashProgress | null) => { + setProgress((current) => (progressEquals(current, nextProgress) ? current : nextProgress)) + }, []) useEffect(() => { lastSeqRef.current = lastSeq @@ -535,9 +567,9 @@ function McpBashExecSessionView({ }, [isChatNearBottom, isInline]) useEffect(() => { - setSessionStatus(initialStatus) - setExitCode(initialExitCode) - setStopReason(initialStopReason) + setSessionStatus((current) => (current === initialStatus ? current : initialStatus)) + setExitCode((current) => (current === initialExitCode ? current : initialExitCode)) + setStopReason((current) => (current === initialStopReason ? current : initialStopReason)) }, [initialExitCode, initialStatus, initialStopReason, bashId]) useEffect(() => { @@ -559,7 +591,7 @@ function McpBashExecSessionView({ setStopReason(session.stop_reason) } if (session.last_progress) { - setProgress(session.last_progress) + setProgressIfChanged(session.last_progress) } } catch { // Ignore session fetch errors; old events can still render from local payloads. @@ -569,26 +601,40 @@ function McpBashExecSessionView({ return () => { active = false } - }, [bashId, mode, projectId]) + }, [bashId, mode, projectId, setProgressIfChanged]) useEffect(() => { if (resolvedSession?.status) { - setSessionStatus(resolvedSession.status as BashSessionStatus) + setSessionStatus((current) => + current === resolvedSession.status ? current : (resolvedSession.status as BashSessionStatus) + ) } if (typeof resolvedSession?.exit_code === 'number') { - setExitCode(resolvedSession.exit_code) + setExitCode((current) => (current === resolvedSession.exit_code ? current : resolvedSession.exit_code)) } if (typeof resolvedSession?.stop_reason === 'string') { - setStopReason(resolvedSession.stop_reason) + setStopReason((current) => + current === resolvedSession.stop_reason ? current : resolvedSession.stop_reason + ) } }, [resolvedSession?.exit_code, resolvedSession?.status, resolvedSession?.stop_reason]) useEffect(() => { - setProgress(null) + setProgressIfChanged(null) setFallbackOutput('') - }, [bashId, mode]) + }, [bashId, mode, setProgressIfChanged]) useEffect(() => { + const nextKey = buildLiveStateKey({ + status: sessionStatus, + exitCode, + stopReason, + progress, + }) + if (lastLiveStateKeyRef.current === nextKey) { + return + } + lastLiveStateKeyRef.current = nextKey onLiveStateChange?.({ status: sessionStatus, exitCode, @@ -606,9 +652,9 @@ function McpBashExecSessionView({ : null const nextProgress = fromResult ?? resolvedSession?.last_progress ?? null if (nextProgress) { - setProgress(nextProgress) + setProgressIfChanged(nextProgress) } - }, [resolvedSession?.last_progress, resultPayload]) + }, [resolvedSession?.last_progress, resultPayload, setProgressIfChanged]) useEffect(() => { if (!projectId || !preferBashTerminalRender) return @@ -665,15 +711,15 @@ function McpBashExecSessionView({ if (isBashProgressMarker(line)) { const nextProgress = parseBashProgressMarker(line) if (nextProgress) { - setProgress(nextProgress) + setProgressIfChanged(nextProgress) } return } const marker = parseBashStatusMarker(line) if (marker) { - setSessionStatus(marker.status) - setExitCode(marker.exitCode) - setStopReason(marker.reason) + setSessionStatus((current) => (current === marker.status ? current : marker.status)) + setExitCode((current) => (current === marker.exitCode ? current : marker.exitCode)) + setStopReason((current) => (current === marker.reason ? current : marker.reason)) return } const parsed = splitBashLogLine(line) @@ -683,7 +729,7 @@ function McpBashExecSessionView({ } appendToTerminal(`${parsed.text}\n`) }, - [appendToTerminal] + [appendToTerminal, setProgressIfChanged] ) const handleSnapshot = useCallback( @@ -713,7 +759,7 @@ function McpBashExecSessionView({ setLogTruncated(false) } if (event.progress) { - setProgress(event.progress) + setProgressIfChanged(event.progress) } let maxSeq: number | null = latestSeq ?? null event.lines?.forEach((line) => { @@ -726,7 +772,7 @@ function McpBashExecSessionView({ setLastSeq(maxSeq) } }, - [appendToTerminal, command, handleLogLine, resetTerminal, workdir] + [appendToTerminal, command, handleLogLine, resetTerminal, setProgressIfChanged, workdir] ) const handleLogBatch = useCallback( @@ -871,7 +917,7 @@ function McpBashExecSessionView({ onSnapshot: handleSnapshot, onLogBatch: handleLogBatch, onProgress: (event) => { - setProgress(event) + setProgressIfChanged(event) }, onLog: (event) => { hasSnapshotRef.current = true @@ -900,10 +946,12 @@ function McpBashExecSessionView({ }, onDone: (event) => { if (event?.status) { - setSessionStatus(event.status as BashSessionStatus) + setSessionStatus((current) => + current === event.status ? current : (event.status as BashSessionStatus) + ) } if (typeof event?.exit_code === 'number') { - setExitCode(event.exit_code) + setExitCode((current) => (current === event.exit_code ? current : event.exit_code)) } }, }) diff --git a/src/ui/src/components/workspace/CopilotDockOverlay.tsx b/src/ui/src/components/workspace/CopilotDockOverlay.tsx index eb1f58f4..5775a51a 100644 --- a/src/ui/src/components/workspace/CopilotDockOverlay.tsx +++ b/src/ui/src/components/workspace/CopilotDockOverlay.tsx @@ -13,7 +13,6 @@ import type { import OrbitLogoStatus from '@/lib/plugins/ai-manus/components/OrbitLogoStatus' import type { ChatSurface } from '@/lib/types/chat-events' import { Noise } from '@/components/react-bits' -import RotatingText from '@/components/RotatingText' import { cn } from '@/lib/utils' import { COPILOT_FILES_ENABLED } from '@/lib/feature-flags' import { @@ -680,6 +679,15 @@ export function CopilotDockOverlay({ setCopilotMeta((prev) => (isCopilotMetaEqual(prev, next) ? prev : next)) }, []) + const dockCallbacksValue = React.useMemo( + () => ({ + onActionsChange: handleActionsChange, + onMetaChange: handleMetaChange, + onHeaderExtraChange: setHeaderExtraContent, + }), + [handleActionsChange, handleMetaChange] + ) + const logHistoryToggle = React.useCallback((next: boolean) => { if (typeof window === 'undefined') return if (process.env.NODE_ENV !== 'production' || window.localStorage.getItem('ds_debug_copilot') === '1') { @@ -826,7 +834,7 @@ export function CopilotDockOverlay({ : [statusText] : [] const showStatus = statusTexts.length > 0 - const statusAnimate = statusTexts.length > 1 + const visibleStatusText = statusTexts[statusTexts.length - 1] || '' const historyOpen = historyOpenOverride const headerOrbitResetKey = `${copilotMeta?.threadId ?? projectId}:${copilotMeta?.statusKey ?? 0}:${ copilotMeta?.isResponding ? 'busy' : 'idle' @@ -953,7 +961,7 @@ export function CopilotDockOverlay({ >
- +
@@ -981,33 +989,14 @@ export function CopilotDockOverlay({ {showStatus ? ( <> · - + {visibleStatusText} ) : null}
{headerBadges.length > 0 ? ( - {headerBadges.map((badge) => ( ))} - +
) : null}
@@ -1101,11 +1090,7 @@ export function CopilotDockOverlay({
{hasCustomBody ? ( bodyContent diff --git a/src/ui/src/components/workspace/QuestBashExecOperation.tsx b/src/ui/src/components/workspace/QuestBashExecOperation.tsx index 7abd6786..a45b269c 100644 --- a/src/ui/src/components/workspace/QuestBashExecOperation.tsx +++ b/src/ui/src/components/workspace/QuestBashExecOperation.tsx @@ -14,6 +14,7 @@ import { McpBashExecView } from '@/components/chat/toolViews/McpBashExecView' import { useI18n } from '@/lib/i18n/useI18n' import type { ToolContent } from '@/lib/plugins/ai-manus/types' import type { EventMetadata } from '@/lib/types/chat-events' +import type { BashExecLiveState } from '@/components/chat/toolViews/types' import type { BashProgress } from '@/lib/types/bash' import { formatProgressLabel, formatProgressMeta, getProgressPercent } from '@/lib/utils/bash-progress' import { cn } from '@/lib/utils' @@ -161,7 +162,20 @@ function formatPercentLabel(value: number | null) { return Number.isInteger(rounded) ? `${rounded.toFixed(0)}%` : `${rounded.toFixed(1)}%` } -export function QuestBashExecOperation({ +function stableProgressKey(value: BashProgress | null) { + if (!value) return '' + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +function progressEquals(left: BashProgress | null, right: BashProgress | null) { + return left === right || stableProgressKey(left) === stableProgressKey(right) +} + +export const QuestBashExecOperation = React.memo(function QuestBashExecOperation({ questId, itemId, toolCallId, @@ -196,9 +210,9 @@ export function QuestBashExecOperation({ const { t } = useI18n('workspace') const timestamp = createdAt ? Date.parse(createdAt) : Date.now() const resolvedTimestamp = Number.isFinite(timestamp) ? timestamp : Date.now() - const parsedArgs = asRecord(parseStructuredValue(args)) - const parsedOutput = extractBashResult(output) - const initialProgress = extractInitialProgress(parsedOutput) + const parsedArgs = React.useMemo(() => asRecord(parseStructuredValue(args)), [args]) + const parsedOutput = React.useMemo(() => extractBashResult(output), [output]) + const initialProgress = React.useMemo(() => extractInitialProgress(parsedOutput), [parsedOutput]) const mode = typeof parsedArgs?.mode === 'string' ? parsedArgs.mode.trim().toLowerCase() : '' const command = typeof parsedArgs?.command === 'string' @@ -231,26 +245,26 @@ export function QuestBashExecOperation({ ? parsedArgs.id : '' const exitCode = typeof parsedOutput?.exit_code === 'number' ? parsedOutput.exit_code : null - const [liveProgress, setLiveProgress] = React.useState(initialProgress) - const [liveStatus, setLiveStatus] = React.useState( + const initialStatus = typeof parsedOutput?.status === 'string' ? parsedOutput.status : typeof status === 'string' ? status : null - ) + const initialStopReason = typeof parsedOutput?.stop_reason === 'string' ? parsedOutput.stop_reason : '' + const [liveProgress, setLiveProgress] = React.useState(initialProgress) + const [liveStatus, setLiveStatus] = React.useState(initialStatus) const [liveExitCode, setLiveExitCode] = React.useState(exitCode) - const [liveStopReason, setLiveStopReason] = React.useState( - typeof parsedOutput?.stop_reason === 'string' ? parsedOutput.stop_reason : '' - ) + const [liveStopReason, setLiveStopReason] = React.useState(initialStopReason) + const setLiveProgressIfChanged = React.useCallback((nextProgress: BashProgress | null) => { + setLiveProgress((current) => (progressEquals(current, nextProgress) ? current : nextProgress)) + }, []) React.useEffect(() => { - setLiveProgress(initialProgress) - }, [initialProgress, bashId]) + setLiveProgressIfChanged(initialProgress) + }, [initialProgress, bashId, setLiveProgressIfChanged]) React.useEffect(() => { - setLiveStatus( - typeof parsedOutput?.status === 'string' ? parsedOutput.status : typeof status === 'string' ? status : null - ) - setLiveExitCode(exitCode) - setLiveStopReason(typeof parsedOutput?.stop_reason === 'string' ? parsedOutput.stop_reason : '') - }, [bashId, exitCode, parsedOutput, status]) + setLiveStatus((current) => (current === initialStatus ? current : initialStatus)) + setLiveExitCode((current) => (current === exitCode ? current : exitCode)) + setLiveStopReason((current) => (current === initialStopReason ? current : initialStopReason)) + }, [bashId, exitCode, initialStatus, initialStopReason]) const rawStatus = String( liveStatus || @@ -343,37 +357,53 @@ export function QuestBashExecOperation({ } }, [isLatest, isRunning, shouldAutoExpandRunning]) - const eventMetadata: EventMetadata = { - surface: 'copilot', - quest_id: questId, - session_id: - typeof metadata?.session_id === 'string' && metadata.session_id.trim() - ? metadata.session_id - : `quest:${questId}`, - sender_type: 'agent', - sender_label: 'DeepScientist', - sender_name: 'DeepScientist', - ...(metadata as EventMetadata | undefined), - } + const eventMetadata = React.useMemo( + () => ({ + surface: 'copilot', + quest_id: questId, + session_id: + typeof metadata?.session_id === 'string' && metadata.session_id.trim() + ? metadata.session_id + : `quest:${questId}`, + sender_type: 'agent', + sender_label: 'DeepScientist', + sender_name: 'DeepScientist', + ...(metadata as EventMetadata | undefined), + }), + [metadata, questId] + ) - const toolContent: ToolContent = { - event_id: itemId, - timestamp: resolvedTimestamp, - tool_call_id: toolCallId || itemId, - name: toolName || 'bash_exec', - function: 'mcp__bash_exec__bash_exec', - status: label === 'tool_call' ? 'calling' : 'called', - args: parsedArgs ?? (args ? { raw: args } : {}), - content: - label === 'tool_result' - ? { - ...(parsedOutput ? { result: parsedOutput } : {}), - ...(output && !parsedOutput ? { text: output } : {}), - ...(status ? { status } : {}), - } - : {}, - metadata: eventMetadata, - } + const toolContent = React.useMemo( + () => ({ + event_id: itemId, + timestamp: resolvedTimestamp, + tool_call_id: toolCallId || itemId, + name: toolName || 'bash_exec', + function: 'mcp__bash_exec__bash_exec', + status: label === 'tool_call' ? 'calling' : 'called', + args: parsedArgs ?? (args ? { raw: args } : {}), + content: + label === 'tool_result' + ? { + ...(parsedOutput ? { result: parsedOutput } : {}), + ...(output && !parsedOutput ? { text: output } : {}), + ...(status ? { status } : {}), + } + : {}, + metadata: eventMetadata, + }), + [args, eventMetadata, itemId, label, output, parsedArgs, parsedOutput, resolvedTimestamp, status, toolCallId, toolName] + ) + + const handleLiveStateChange = React.useCallback( + (state: BashExecLiveState) => { + setLiveProgressIfChanged(state.progress) + setLiveStatus((current) => (current === state.status ? current : state.status)) + setLiveExitCode((current) => (current === state.exitCode ? current : state.exitCode)) + setLiveStopReason((current) => (current === state.stopReason ? current : state.stopReason)) + }, + [setLiveProgressIfChanged] + ) return (
{ - setLiveProgress(state.progress) - setLiveStatus(state.status) - setLiveExitCode(state.exitCode) - setLiveStopReason(state.stopReason) - }} + onLiveStateChange={handleLiveStateChange} />
) : null} ) -} +}) export default QuestBashExecOperation diff --git a/src/ui/src/components/workspace/QuestCopilotDockPanel.tsx b/src/ui/src/components/workspace/QuestCopilotDockPanel.tsx index cafb6c3d..7cc005be 100644 --- a/src/ui/src/components/workspace/QuestCopilotDockPanel.tsx +++ b/src/ui/src/components/workspace/QuestCopilotDockPanel.tsx @@ -33,6 +33,11 @@ function isParkedCopilotWorkspace(workspace: QuestWorkspaceState) { const continuationPolicy = String(snapshot?.continuation_policy || '').trim().toLowerCase() const activeRunId = String(snapshot?.active_run_id || '').trim() const bashRunningCount = Number(snapshot?.counts?.bash_running_count || 0) + const runtimeStatus = String(snapshot?.runtime_status || snapshot?.status || '').trim().toLowerCase() + const latestActivityRaw = String(snapshot?.last_tool_activity_at || snapshot?.updated_at || '').trim() + const latestActivityMs = latestActivityRaw ? Date.parse(latestActivityRaw) : Number.NaN + const staleWaitingState = + Number.isFinite(latestActivityMs) && Date.now() - latestActivityMs > 90_000 const latestBashSession = snapshot?.summary?.latest_bash_session && typeof snapshot.summary.latest_bash_session === 'object' && @@ -51,9 +56,10 @@ function isParkedCopilotWorkspace(workspace: QuestWorkspaceState) { !workspace.loading && !workspace.restoring && !workspace.error && - (bashRunningCount === 0 || - (bashRunningCount === 1 && - latestBashKind === 'terminal' && + (runtimeStatus !== 'running' || staleWaitingState) && + (staleWaitingState || + bashRunningCount === 0 || + (latestBashKind === 'terminal' && (latestBashId === '' || latestBashId === 'terminal-main'))) ) } diff --git a/src/ui/src/components/workspace/QuestStudioDirectTimeline.tsx b/src/ui/src/components/workspace/QuestStudioDirectTimeline.tsx index 8ca0103e..17145c38 100644 --- a/src/ui/src/components/workspace/QuestStudioDirectTimeline.tsx +++ b/src/ui/src/components/workspace/QuestStudioDirectTimeline.tsx @@ -220,7 +220,7 @@ function StudioInlineFileText({ }: { questId: string content: string - onOpenFile: (href: string) => void + onOpenFile: (href: string) => void | Promise }) { const parts: Array<{ kind: 'text' | 'file'; value: string }> = [] let lastIndex = 0 @@ -283,7 +283,7 @@ function StudioInlineFileText({ ) } -function StudioMessageBlock({ +const StudioMessageBlock = React.memo(function StudioMessageBlock({ block, questId, markdownComponents, @@ -292,7 +292,7 @@ function StudioMessageBlock({ block: Extract questId: string markdownComponents?: Components - onOpenFile: (href: string) => void + onOpenFile: (href: string) => void | Promise }) { return (
@@ -314,9 +314,9 @@ function StudioMessageBlock({ )}
) -} +}) -function StudioReasoningBlock({ +const StudioReasoningBlock = React.memo(function StudioReasoningBlock({ block, markdownComponents, }: { @@ -361,7 +361,7 @@ function StudioReasoningBlock({ ) -} +}) function isBashExecOperation(block: Extract) { const identity = deriveMcpIdentity( @@ -372,7 +372,7 @@ function isBashExecOperation(block: Extract -} +}) -function StudioArtifactBlock({ block }: { block: Extract }) { +const StudioArtifactBlock = React.memo(function StudioArtifactBlock({ block }: { block: Extract }) { const { t } = useI18n('workspace') const item = block.item const detailEntries = Object.entries(item.details ?? {}).filter(([, value]) => value != null && value !== '') @@ -453,9 +453,9 @@ function StudioArtifactBlock({ block }: { block: Extract ) -} +}) -function StudioEventBlock({ block }: { block: Extract }) { +const StudioEventBlock = React.memo(function StudioEventBlock({ block }: { block: Extract }) { const item = block.item const label = humanizeEventLabel(item.label) const text = [label, item.content ? item.content.trim() : ''].filter(Boolean).join(' · ') @@ -465,18 +465,20 @@ function StudioEventBlock({ block }: { block: Extract{text} ) -} +}) -function AssistantTurn({ +const AssistantTurn = React.memo(function AssistantTurn({ questId, turn, latestOperationId, markdownComponents, + onOpenFile, }: { questId: string turn: StudioTurn latestOperationId: string | null markdownComponents?: Components + onOpenFile: (href: string) => void | Promise }) { const hasStreamingMessage = turn.blocks.some( (block) => @@ -508,9 +510,7 @@ function AssistantTurn({ block={block} questId={questId} markdownComponents={markdownComponents} - onOpenFile={(href) => { - void handleOpenStudioFile(href) - }} + onOpenFile={onOpenFile} /> ) } @@ -537,9 +537,9 @@ function AssistantTurn({ ) -} +}) -function UserTurn({ +const UserTurn = React.memo(function UserTurn({ turn, markdownComponents, onReadNow, @@ -591,9 +591,9 @@ function UserTurn({ ) -} +}) -function SystemTurn({ turn }: { turn: StudioTurn }) { +const SystemTurn = React.memo(function SystemTurn({ turn }: { turn: StudioTurn }) { return (
@@ -603,9 +603,9 @@ function SystemTurn({ turn }: { turn: StudioTurn }) {
) -} +}) -export function QuestStudioDirectTimeline({ +export const QuestStudioDirectTimeline = React.memo(function QuestStudioDirectTimeline({ questId, feed, loading, @@ -834,6 +834,7 @@ export function QuestStudioDirectTimeline({ turn={turn} latestOperationId={latestOperationId} markdownComponents={markdownComponents} + onOpenFile={handleOpenStudioFile} /> ) }) @@ -843,6 +844,6 @@ export function QuestStudioDirectTimeline({ ) -} +}) export default QuestStudioDirectTimeline diff --git a/src/ui/src/components/workspace/WorkspaceLayout.tsx b/src/ui/src/components/workspace/WorkspaceLayout.tsx index 9a60433a..489fce59 100644 --- a/src/ui/src/components/workspace/WorkspaceLayout.tsx +++ b/src/ui/src/components/workspace/WorkspaceLayout.tsx @@ -4113,20 +4113,20 @@ export function WorkspaceLayout({
- +
diff --git a/src/ui/src/lib/acp.ts b/src/ui/src/lib/acp.ts index c1da3abe..863f36a7 100644 --- a/src/ui/src/lib/acp.ts +++ b/src/ui/src/lib/acp.ts @@ -867,14 +867,20 @@ function snapshotIndicatesLiveRun(snapshot?: QuestSummary | null) { Number.isFinite(latestActivityMs) && Date.now() - latestActivityMs > 90_000 + const isStaleCopilotWait = + workspaceMode === 'copilot' && + continuationPolicy === 'wait_for_user_or_resume' && + !activeRunId && + Number.isFinite(latestActivityMs) && + Date.now() - latestActivityMs > 90_000 const isParkedCopilot = workspaceMode === 'copilot' && continuationPolicy === 'wait_for_user_or_resume' && !activeRunId && - runtimeStatus !== 'running' && - (bashRunningCount === 0 || - (bashRunningCount === 1 && - latestBashKind === 'terminal' && + (runtimeStatus !== 'running' || isStaleCopilotWait) && + (isStaleCopilotWait || + bashRunningCount === 0 || + (latestBashKind === 'terminal' && (latestBashId === '' || latestBashId === 'terminal-main'))) if (isParkedCopilot) return false if (staleRunningWithoutSignals) return false diff --git a/src/ui/src/lib/hooks/useBashSessionStream.ts b/src/ui/src/lib/hooks/useBashSessionStream.ts index d1fd3abf..d1282447 100644 --- a/src/ui/src/lib/hooks/useBashSessionStream.ts +++ b/src/ui/src/lib/hooks/useBashSessionStream.ts @@ -61,13 +61,41 @@ const isLikelyNetworkStreamError = (error: unknown) => { const sortSessions = (sessions: BashSession[]) => [...sessions].sort((a, b) => Date.parse(b.started_at) - Date.parse(a.started_at)) +const sessionFingerprint = (session: BashSession) => { + try { + return JSON.stringify(session) + } catch { + return String(session.bash_id || '') + } +} + +const sessionEquals = (left: BashSession | undefined, right: BashSession | undefined) => { + if (left === right) return true + if (!left || !right) return false + return sessionFingerprint(left) === sessionFingerprint(right) +} + +const sessionsEqual = (left: BashSession[], right: BashSession[]) => { + if (left === right) return true + if (left.length !== right.length) return false + for (let index = 0; index < left.length; index += 1) { + if (left[index]?.bash_id !== right[index]?.bash_id) return false + if (!sessionEquals(left[index], right[index])) return false + } + return true +} + const mergeSessions = (previous: BashSession[], incoming: BashSession) => { if (!incoming?.bash_id) return previous const map = new Map(previous.map((session) => [session.bash_id, session])) const existing = map.get(incoming.bash_id) const merged = existing ? { ...existing, ...incoming } : incoming + if (existing && sessionEquals(existing, merged)) { + return previous + } map.set(incoming.bash_id, merged) - return sortSessions(Array.from(map.values())) + const next = sortSessions(Array.from(map.values())) + return sessionsEqual(previous, next) ? previous : next } const buildAuthContext = () => { @@ -85,6 +113,8 @@ const handleUnauthorized = (authMode: RequestAuthMode) => { handleUnauthorizedAuth(authMode, 'session_expired') } +const SESSION_STREAM_FLUSH_MS = 1000 + export function useBashSessionStream({ projectId, agentInstanceIds, @@ -105,6 +135,8 @@ export function useBashSessionStream({ const abortRef = useRef(null) const reconnectRef = useRef(null) const hasSnapshotRef = useRef(false) + const pendingSessionEventsRef = useRef>(new Map()) + const sessionFlushTimerRef = useRef(null) const queryKey = useMemo(() => { return [ @@ -125,9 +157,46 @@ export function useBashSessionStream({ ]) const updateConnection = useCallback((next: BashSessionStreamState) => { - setConnection(next) + setConnection((current) => + current.status === next.status && current.error === next.error ? current : next + ) }, []) + const flushPendingSessionEvents = useCallback(() => { + sessionFlushTimerRef.current = null + const pending = Array.from(pendingSessionEventsRef.current.values()) + pendingSessionEventsRef.current.clear() + if (pending.length === 0) return + setSessions((current) => { + let next = current + for (const session of pending) { + next = mergeSessions(next, session) + } + return next + }) + }, []) + + const clearPendingSessionEvents = useCallback(() => { + pendingSessionEventsRef.current.clear() + if (sessionFlushTimerRef.current != null) { + window.clearTimeout(sessionFlushTimerRef.current) + sessionFlushTimerRef.current = null + } + }, []) + + const queueSessionEvent = useCallback( + (session: BashSession) => { + if (!session?.bash_id) return + pendingSessionEventsRef.current.set(session.bash_id, session) + if (sessionFlushTimerRef.current != null) return + sessionFlushTimerRef.current = window.setTimeout( + flushPendingSessionEvents, + SESSION_STREAM_FLUSH_MS + ) + }, + [flushPendingSessionEvents] + ) + const stopStream = useCallback(() => { if (abortRef.current) { abortRef.current.abort() @@ -137,7 +206,8 @@ export function useBashSessionStream({ window.clearTimeout(reconnectRef.current) reconnectRef.current = null } - }, []) + clearPendingSessionEvents() + }, [clearPendingSessionEvents]) const reload = useCallback(async () => { if (!enabled || !projectId) { @@ -152,7 +222,10 @@ export function useBashSessionStream({ chatSessionId: chatSessionId ?? undefined, limit, }) - setSessions(sortSessions(response)) + setSessions((current) => { + const next = sortSessions(response) + return sessionsEqual(current, next) ? current : next + }) } catch (error) { updateConnection({ status: 'error', @@ -250,13 +323,16 @@ export function useBashSessionStream({ const data = JSON.parse(parsed.data) as { sessions?: BashSession[] } if (Array.isArray(data.sessions)) { hasSnapshotRef.current = true - setSessions(sortSessions(data.sessions)) + setSessions((current) => { + const next = sortSessions(data.sessions) + return sessionsEqual(current, next) ? current : next + }) } } if (parsed.event === 'session') { const data = JSON.parse(parsed.data) as { session?: BashSession } if (data.session) { - setSessions((current) => mergeSessions(current, data.session as BashSession)) + queueSessionEvent(data.session as BashSession) } } } catch (error) { @@ -276,13 +352,16 @@ export function useBashSessionStream({ const data = JSON.parse(parsed.data) as { sessions?: BashSession[] } if (Array.isArray(data.sessions)) { hasSnapshotRef.current = true - setSessions(sortSessions(data.sessions)) + setSessions((current) => { + const next = sortSessions(data.sessions) + return sessionsEqual(current, next) ? current : next + }) } } if (parsed.event === 'session') { const data = JSON.parse(parsed.data) as { session?: BashSession } if (data.session) { - setSessions((current) => mergeSessions(current, data.session as BashSession)) + queueSessionEvent(data.session as BashSession) } } } catch (error) { @@ -331,6 +410,7 @@ export function useBashSessionStream({ normalizedAgentIds.key, normalizedAgentInstanceIds.key, projectId, + queueSessionEvent, reload, stopStream, status, diff --git a/src/ui/src/lib/plugins/ai-manus/components/OrbitLogoStatus.tsx b/src/ui/src/lib/plugins/ai-manus/components/OrbitLogoStatus.tsx index ae7c1f02..f8db8dc7 100644 --- a/src/ui/src/lib/plugins/ai-manus/components/OrbitLogoStatus.tsx +++ b/src/ui/src/lib/plugins/ai-manus/components/OrbitLogoStatus.tsx @@ -197,7 +197,7 @@ export function OrbitLogoStatus({ rafRef.current = window.requestAnimationFrame(tick) } rafRef.current = window.requestAnimationFrame(tick) - }, [drawFrame, prefersReducedMotion]) + }, [drawFrame, shouldAnimate]) useEffect(() => { if (prefersReducedMotion) { diff --git a/src/ui/src/pages/ProjectWorkspacePage.tsx b/src/ui/src/pages/ProjectWorkspacePage.tsx index 01b51b14..80cccf19 100644 --- a/src/ui/src/pages/ProjectWorkspacePage.tsx +++ b/src/ui/src/pages/ProjectWorkspacePage.tsx @@ -14,12 +14,12 @@ function AtmosphereFrame({ children }: { children: ReactNode }) { return (
-
+
- +
{children}