Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 70 additions & 22 deletions src/ui/src/components/chat/toolViews/McpBashExecView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -496,6 +523,11 @@ function McpBashExecSessionView({
const hasSnapshotRef = useRef(false)
const initialLoadAttemptedRef = useRef(false)
const snapshotTimerRef = useRef<number | null>(null)
const lastLiveStateKeyRef = useRef('')

const setProgressIfChanged = useCallback((nextProgress: BashProgress | null) => {
setProgress((current) => (progressEquals(current, nextProgress) ? current : nextProgress))
}, [])

useEffect(() => {
lastSeqRef.current = lastSeq
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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.
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -683,7 +729,7 @@ function McpBashExecSessionView({
}
appendToTerminal(`${parsed.text}\n`)
},
[appendToTerminal]
[appendToTerminal, setProgressIfChanged]
)

const handleSnapshot = useCallback(
Expand Down Expand Up @@ -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) => {
Expand All @@ -726,7 +772,7 @@ function McpBashExecSessionView({
setLastSeq(maxSeq)
}
},
[appendToTerminal, command, handleLogLine, resetTerminal, workdir]
[appendToTerminal, command, handleLogLine, resetTerminal, setProgressIfChanged, workdir]
)

const handleLogBatch = useCallback(
Expand Down Expand Up @@ -871,7 +917,7 @@ function McpBashExecSessionView({
onSnapshot: handleSnapshot,
onLogBatch: handleLogBatch,
onProgress: (event) => {
setProgress(event)
setProgressIfChanged(event)
},
onLog: (event) => {
hasSnapshotRef.current = true
Expand Down Expand Up @@ -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))
}
},
})
Expand Down
45 changes: 15 additions & 30 deletions src/ui/src/components/workspace/CopilotDockOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -953,7 +961,7 @@ export function CopilotDockOverlay({
>
<div className="relative h-full w-full overflow-hidden rounded-[18px]">
<div className="ds-copilot-glass">
<Noise size={260} className="ds-copilot-noise opacity-[0.06]" />
<Noise size={260} animated={false} className="ds-copilot-noise opacity-[0.06]" />

<div className="ds-copilot-glass-inner">
<div className="ds-copilot-header" onPointerDown={handleHeaderDrag}>
Expand Down Expand Up @@ -981,33 +989,14 @@ export function CopilotDockOverlay({
{showStatus ? (
<>
<span className="ds-copilot-title-sep">·</span>
<RotatingText
key={`copilot-status-${statusKey}`}
texts={statusTexts}
auto={statusAnimate}
loop={false}
rotationInterval={1200}
staggerFrom="last"
staggerDuration={0.02}
initial={{ y: '90%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '-120%', opacity: 0 }}
animatePresenceInitial
maxWords={14}
mainClassName="ds-copilot-status-text"
splitLevelClassName="ds-copilot-status-text-split"
elementLevelClassName="ds-copilot-status-text-element"
/>
<span className="ds-copilot-status-text">{visibleStatusText}</span>
</>
) : null}
</div>
{headerBadges.length > 0 ? (
<motion.div
<div
data-testid="copilot-header-badges"
className="ds-copilot-header-badges"
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={prefersReducedMotion ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
>
{headerBadges.map((badge) => (
<span
Expand All @@ -1018,7 +1007,7 @@ export function CopilotDockOverlay({
{badge.label}
</span>
))}
</motion.div>
</div>
) : null}
</div>
</div>
Expand Down Expand Up @@ -1101,11 +1090,7 @@ export function CopilotDockOverlay({
<div className="ds-copilot-chat">
<CopilotDockHeaderPortalContext.Provider value={headerPortalEl}>
<CopilotDockCallbacksContext.Provider
value={{
onActionsChange: handleActionsChange,
onMetaChange: handleMetaChange,
onHeaderExtraChange: setHeaderExtraContent,
}}
value={dockCallbacksValue}
>
{hasCustomBody ? (
bodyContent
Expand Down
Loading