Skip to content

Commit 65c9cf2

Browse files
improvement(mothership): swap actions into the thinking slot at settle, quicken the chase
1 parent 8ac08dd commit 65c9cf2

4 files changed

Lines changed: 58 additions & 68 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 35 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
'use client'
22

3-
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
3+
import {
4+
memo,
5+
type ReactNode,
6+
useCallback,
7+
useEffect,
8+
useLayoutEffect,
9+
useMemo,
10+
useRef,
11+
useState,
12+
} from 'react'
413
import { cn } from '@sim/emcn'
514
import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
615
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
@@ -21,8 +30,6 @@ import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils'
2130
const FILE_SUBAGENT_ID = 'file'
2231
/** Quiet period before the shimmer takes the slot back from streamed output. */
2332
const STREAM_IDLE_DELAY_MS = 1_500
24-
/** Unmount the loader after the slot's 300ms collapse, with margin. */
25-
const SLOT_EXIT_DELAY_MS = 400
2633

2734
interface TextSegment {
2835
type: 'text'
@@ -785,6 +792,14 @@ interface MessageContentProps {
785792
onOptionSelect?: (id: string) => void
786793
onQuestionDismiss?: () => void
787794
onPhaseChange?: (phase: MessagePhase) => void
795+
/**
796+
* The message's actions row (copy/thumbs). Rendered here, in the thinking
797+
* slot's position, so at settle the shimmer and the actions trade places in
798+
* one render — a single tiny reflow instead of a collapse the buttons ride
799+
* or a late mount the chase visibly scrolls to. The caller gates it on
800+
* content/question eligibility only; the settle timing is owned here.
801+
*/
802+
actions?: ReactNode
788803
}
789804

790805
function MessageContentInner({
@@ -796,6 +811,7 @@ function MessageContentInner({
796811
onOptionSelect,
797812
onQuestionDismiss,
798813
onPhaseChange,
814+
actions,
799815
}: MessageContentProps) {
800816
const { onWorkspaceResourceSelect } = useChatSurface()
801817
const parsed = useMemo(() => (blocks.length > 0 ? parseBlocks(blocks) : []), [blocks])
@@ -813,16 +829,6 @@ function MessageContentInner({
813829
setTrailingPendingTag(pending)
814830
}, [])
815831
const [isStreamIdle, setIsStreamIdle] = useState(false)
816-
/**
817-
* True once the slot's collapse has finished (seeded true so a settled mount
818-
* never runs the loader). The loader must stay mounted through the collapse:
819-
* with `grid-rows` transitioning 1fr→0fr, unmounting the content in the same
820-
* flip zeroes the track instantly — the slot snaps instead of sliding out,
821-
* clamping `scrollTop` at settle. Timed rather than transitionend-latched:
822-
* motion-reduce (`transition-none`) and browsers that can't animate
823-
* `grid-template-rows` never fire the event.
824-
*/
825-
const [slotExited, setSlotExited] = useState(true)
826832

827833
const segments: MessageSegment[] =
828834
parsed.length > 0
@@ -864,15 +870,6 @@ function MessageContentInner({
864870
// winking out early while everything shifts.
865871
const thinkingExpanded = phase !== 'settled' && lastSegment?.type !== 'stopped'
866872

867-
useEffect(() => {
868-
if (thinkingExpanded) return
869-
const timeout = setTimeout(() => setSlotExited(true), SLOT_EXIT_DELAY_MS)
870-
return () => clearTimeout(timeout)
871-
}, [thinkingExpanded])
872-
873-
// Guarded render adjust (see sim-hooks): a live turn re-arms the exit latch.
874-
if (thinkingExpanded && slotExited) setSlotExited(false)
875-
876873
if (segments.length === 0 && !isLast) return null
877874

878875
// A visible executing tool row already spins — the turn-level shimmer would
@@ -956,37 +953,26 @@ function MessageContentInner({
956953
}
957954
})}
958955
</div>
959-
{isLast && (
956+
{thinkingExpanded && isLast ? (
960957
// Fixed-height placeholder for the NEXT piece of output: the shimmer
961958
// and arriving output trade places via opacity only, so mid-turn swaps
962-
// can't move layout; the grid-rows collapse slides it out once, at
963-
// settle. A sibling of the space-y stack (not a child), so collapsed
964-
// it carries no leftover sibling margin — pt-[10px] is its own gap.
965-
<div
966-
aria-hidden={!showShimmer}
967-
className={cn(
968-
'grid transition-[grid-template-rows,opacity] duration-300 ease-out motion-reduce:transition-none',
969-
thinkingExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
970-
)}
971-
>
972-
<div className='overflow-hidden'>
973-
<div
974-
className={cn(
975-
'pt-[10px] transition-transform duration-300 ease-out motion-reduce:transition-none',
976-
thinkingExpanded ? 'translate-y-0' : 'translate-y-[8px]'
977-
)}
978-
>
979-
<div
980-
className={cn(
981-
'transition-opacity duration-200 ease-out',
982-
showShimmer ? 'opacity-100' : 'opacity-0'
983-
)}
984-
>
985-
{!slotExited && <PendingTagIndicator label={thinkingLabel ?? 'Thinking…'} />}
986-
</div>
987-
</div>
959+
// can't move layout. A sibling of the space-y stack (not a child), so
960+
// it carries no stray sibling margin — pt-[10px] is its own gap.
961+
<div aria-hidden={!showShimmer} className='pt-[10px]'>
962+
<div
963+
className={cn(
964+
'transition-opacity duration-200 ease-out',
965+
showShimmer ? 'opacity-100' : 'opacity-0'
966+
)}
967+
>
968+
<PendingTagIndicator label={thinkingLabel ?? 'Thinking…'} />
988969
</div>
989970
</div>
971+
) : (
972+
// The actions row takes the slot's place in the SAME render — a single
973+
// ~10px reflow instead of a collapse the buttons would ride upward or a
974+
// late mount the chase would visibly scroll to.
975+
actions && <div className='mt-2.5'>{actions}</div>
990976
)}
991977
</div>
992978
)

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,11 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
219219
const handleQuestionDismiss = () => {
220220
if (questionTag) setDismissedQuestionTag(questionTag)
221221
}
222-
const showActions = shouldShowAssistantMessageActions({
223-
phase,
222+
// Settle timing lives in MessageContent (the actions take the thinking
223+
// slot's place in the same render), so eligibility here is phase-free:
224+
// `phase: 'settled'` asks the helper "would a settled turn show them?".
225+
const actionsEligible = shouldShowAssistantMessageActions({
226+
phase: 'settled',
224227
hasContent: Boolean(message.content) || hasAnyBlocks,
225228
endsWithQuestion,
226229
questionDismissed,
@@ -237,17 +240,17 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
237240
onOptionSelect={onOptionSelect}
238241
onQuestionDismiss={handleQuestionDismiss}
239242
onPhaseChange={setPhase}
243+
actions={
244+
actionsEligible ? (
245+
<MessageActions
246+
content={message.content}
247+
userQuery={precedingUserContent}
248+
requestId={message.requestId}
249+
messageId={message.id}
250+
/>
251+
) : undefined
252+
}
240253
/>
241-
{showActions && (
242-
<div className='mt-2.5'>
243-
<MessageActions
244-
content={message.content}
245-
userQuery={precedingUserContent}
246-
requestId={message.requestId}
247-
messageId={message.id}
248-
/>
249-
</div>
250-
)}
251254
</div>
252255
)
253256
})

apps/sim/hooks/use-auto-scroll.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ const ANIMATION_FOLLOW_WINDOW = 500
3030
/**
3131
* How long to keep chasing the bottom after streaming stops. End-of-turn content
3232
* mounts just after `isStreaming` flips false — the suggested-follow-up options,
33-
* the actions row (gated on `!isStreaming`), and the virtualizer's re-measure of
34-
* the grown row — so a single final scroll fires before it lays out and leaves it
35-
* clipped behind the input. Following for a short window pulls it into view.
33+
* the actions row (swapped into the thinking slot's place), and the
34+
* virtualizer's re-measure of the grown row — so a single final scroll fires
35+
* before it lays out and leaves it clipped behind the input. Following for a
36+
* short window pulls it into view.
3637
*/
3738
const POST_STREAM_SETTLE_WINDOW = 300
3839

apps/sim/lib/core/utils/smooth-bottom-chase.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
* Fraction of the remaining gap to close per frame while chasing the bottom —
33
* an exponential glide (originating in the subagent viewport's stick-to-bottom,
44
* see BoundedViewport in agent-group.tsx) instead of snapping `scrollTop` to
5-
* `scrollHeight` on every content append. Closes ~90% of any gap within ~18
6-
* frames (~300ms) — deliberately lazier than the subagent viewport's 0.18 so a
7-
* large content burst reads as a calm upward drift of the transcript rather
5+
* `scrollHeight` on every content append. Closes ~90% of any gap within ~13
6+
* frames (~220ms) — slightly lazier than the subagent viewport's 0.18 so a
7+
* large content burst reads as a brisk upward drift of the transcript rather
88
* than a lurch.
99
*/
10-
export const SMOOTH_CHASE_RATE = 0.12
10+
export const SMOOTH_CHASE_RATE = 0.16
1111

1212
/** Gap (px) below which the chase parks until new growth reopens it. */
1313
export const CHASE_REST_GAP = 0.5

0 commit comments

Comments
 (0)