@@ -86,7 +86,10 @@ import {
8686 resolveWorkflowOpening ,
8787 workflowOpeningParentThreadId ,
8888} from "@t3tools/client-runtime/state/subagentRuntime" ;
89- import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows" ;
89+ import {
90+ getUserQueuedThreadRuns ,
91+ threadSupportsProviderHandoff ,
92+ } from "@t3tools/client-runtime/state/thread-workflows" ;
9093import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff" ;
9194import { HandoffRow } from "./chat/V2LifecycleRow" ;
9295import {
@@ -126,8 +129,6 @@ import { useActiveThreadRef } from "../hooks/useActiveThreadRef";
126129import { composerRequiresModifier } from "../composer-logic" ;
127130import {
128131 derivePhase ,
129- deriveActivePlanState ,
130- selectComposerTasksPlan ,
131132 deriveActiveWorkStartedAt ,
132133 deriveCanInterruptRunningThread ,
133134 findLatestProposedPlan ,
@@ -352,6 +353,8 @@ import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers";
352353import { ChatRightPanel } from "./chat/ChatRightPanel" ;
353354import { useDraftEnvironmentRouting } from "./chat/useDraftEnvironmentRouting" ;
354355import { branchMismatchBannerItem } from "./chat/ThreadActivityBanners" ;
356+ import { ThreadNow } from "./chat/ThreadNow" ;
357+ import { useDelayedStatus } from "../hooks/useDelayedStatus" ;
355358import { useTimelineFollow } from "./chat/useTimelineFollow" ;
356359import {
357360 type EnvironmentUnavailableState ,
@@ -512,6 +515,8 @@ export default function ChatView(props: ChatViewProps) {
512515 status : threadStatus ,
513516 } ) ;
514517 const threadDetailLoading = threadSyncPhase === "loading" ;
518+ // Held back briefly, so a quick sync does not flash a status line at the end of the timeline.
519+ const delayedThreadSyncPhase = useDelayedStatus ( routeThreadKey , threadSyncPhase ) ;
515520 const activeProviderThread = serverProjection ?. providerThreads . find (
516521 ( thread ) => thread . id === serverProjection . thread . activeProviderThreadId ,
517522 ) ;
@@ -989,30 +994,6 @@ export default function ChatView(props: ChatViewProps) {
989994 ) ;
990995
991996 const latestRunSettled = isLatestRunSettled ( activeLatestRun , activeRuntime ) ;
992- const activePlan = deriveActivePlanState ( serverProjection , activeActivityRun ?. runId ) ;
993- const activeActivityRunSettled = isLatestRunSettled ( activeActivityRun , activeRuntime ) ;
994- const composerTasksPlan = selectComposerTasksPlan (
995- serverProjection ,
996- activePlan ,
997- activeActivityRun ?. runId ?? null ,
998- activeActivityRunSettled ,
999- ) ;
1000- const activeComposerTasksProgress = ( ( ) => {
1001- if ( ! composerTasksPlan ) return null ;
1002- const live = ! activeActivityRunSettled && composerTasksPlan . runId === activeActivityRun ?. runId ;
1003- const totalSteps = composerTasksPlan . steps . length ;
1004- if ( totalSteps === 0 ) return null ;
1005- const completedSteps = composerTasksPlan . steps . filter (
1006- ( step ) => step . status === "completed" ,
1007- ) . length ;
1008- const step =
1009- composerTasksPlan . steps . find ( ( candidate ) => candidate . status === "inProgress" ) ?. step ??
1010- composerTasksPlan . steps . find ( ( candidate ) => candidate . status === "pending" ) ?. step ??
1011- composerTasksPlan . steps . at ( - 1 ) ! . step ;
1012- return { step, completedSteps, totalSteps, live } ;
1013- } ) ( ) ;
1014- const activeComposerTaskSteps =
1015- activeComposerTasksProgress && composerTasksPlan ? composerTasksPlan . steps : null ;
1016997 const activeThreadEnvironmentId = activeThread ?. environmentId ;
1017998 const activeThreadProjectId = activeThread ?. projectId ;
1018999 const activeProjectRef =
@@ -2007,6 +1988,7 @@ export default function ChatView(props: ChatViewProps) {
20071988 onTimelineAnchorReadyStable,
20081989 onTimelineAnchorSizeChangedStable,
20091990 onToolOutputCollapsedAtEndStable,
1991+ onTimelineFooterGrowStable,
20101992 onIsAtEndChangeStable,
20111993 anchorNewTurn,
20121994 attachTimelineViewport,
@@ -2185,22 +2167,11 @@ export default function ChatView(props: ChatViewProps) {
21852167 modelStrip . offsetHeight + Number . parseFloat ( getComputedStyle ( modelStrip ) . marginTop ) ,
21862168 )
21872169 : 0 ;
2188- const noticeStack = composerOverlayElement ?. querySelector < HTMLElement > (
2189- '[data-composer-banner-stack="true"]' ,
2190- ) ;
2191- const expandedNoticesHeight = noticeStack
2192- ? Math . max (
2193- 0 ,
2194- noticeStack . getBoundingClientRect ( ) . height -
2195- Number . parseFloat ( getComputedStyle ( noticeStack ) . minHeight ) ,
2196- )
2197- : 0 ;
21982170 const nextInset = resolveComposerTimelineInset ( {
21992171 currentInset : composerTimelineInsetRef . current ,
22002172 overlayHeight : height ,
22012173 isResting,
22022174 restingOnlyHeight,
2203- expandedNoticesHeight,
22042175 } ) ;
22052176 if ( composerTimelineInsetRef . current !== nextInset ) {
22062177 composerTimelineInsetRef . current = nextInset ;
@@ -2226,9 +2197,8 @@ export default function ChatView(props: ChatViewProps) {
22262197 const mainSurface = composerOverlayElement ?. querySelector < HTMLElement > (
22272198 '[data-chat-composer-main-surface="true"]' ,
22282199 ) ;
2229- const button = composerOverlayElement ?. parentElement ?. querySelector < HTMLElement > (
2230- 'button[aria-label="Scroll to end"]' ,
2231- ) ;
2200+ const button =
2201+ composerOverlayElement ?. parentElement ?. querySelector < HTMLElement > ( "[data-scroll-to-end]" ) ;
22322202 const clearance =
22332203 composerOverlayElement && mainSurface && button
22342204 ? resolveScrollToEndClearance ( {
@@ -2383,9 +2353,9 @@ export default function ChatView(props: ChatViewProps) {
23832353 const {
23842354 acknowledgeActiveThreadWoke,
23852355 activeBranchMismatchKey,
2386- backgroundWorkBanner ,
2387- childInputBanner ,
2388- goalBanner ,
2356+ backgroundWorkNow ,
2357+ childInputNow ,
2358+ goalNow ,
23892359 handleSwitchCheckoutToThread,
23902360 handleSwitchCheckoutToThreadStable,
23912361 isRestoringThreadBranch,
@@ -2550,9 +2520,6 @@ export default function ChatView(props: ChatViewProps) {
25502520 : null ;
25512521 const composerBannerItems = ( ( ) => {
25522522 const limitRecoveryItems = limitRecoveryBanner === null ? [ ] : [ limitRecoveryBanner ] ;
2553- const backgroundWorkItems = [ childInputBanner , goalBanner , backgroundWorkBanner ] . filter (
2554- ( item ) => item !== null ,
2555- ) ;
25562523 const usageLimitsItems = usageLimitsBanner === null ? [ ] : [ usageLimitsBanner ] ;
25572524 const projectCloneItems = projectCloneBannerItem === null ? [ ] : [ projectCloneBannerItem ] ;
25582525 if ( ! localCheckoutBranchMismatch || ! showBranchMismatchBanner || ! activeBranchMismatchKey ) {
@@ -2562,7 +2529,6 @@ export default function ChatView(props: ChatViewProps) {
25622529 ...usageLimitsItems ,
25632530 ...projectCloneItems ,
25642531 ...systemComposerBannerItems ,
2565- ...backgroundWorkItems ,
25662532 ] ;
25672533 }
25682534 return [
@@ -2571,7 +2537,6 @@ export default function ChatView(props: ChatViewProps) {
25712537 ...usageLimitsItems ,
25722538 ...projectCloneItems ,
25732539 ...systemComposerBannerItems ,
2574- ...backgroundWorkItems ,
25752540 branchMismatchBannerItem ( {
25762541 mismatchKey : activeBranchMismatchKey ,
25772542 mismatch : localCheckoutBranchMismatch ,
@@ -2979,6 +2944,66 @@ export default function ChatView(props: ChatViewProps) {
29792944 openExpandedImagePreview ( routeThreadKey , preview ) ;
29802945 } ;
29812946 const onExpandTimelineImageStable = useStableCallback ( onExpandTimelineImage ) ;
2947+ const shownThreadSyncPhase = activeEnvironmentUnavailable ? null : delayedThreadSyncPhase ;
2948+ const hasThreadNow =
2949+ childInputNow !== null ||
2950+ backgroundWorkNow !== null ||
2951+ goalNow !== null ||
2952+ shownThreadSyncPhase !== null ;
2953+ const queuedRunCount = serverProjection ? getUserQueuedThreadRuns ( serverProjection ) . length : 0 ;
2954+ // The queue control is mounted for every server thread but joins the footer only while it shows
2955+ // something, by its own rule: an optimistic message stops counting once the server has it.
2956+ const hasQueuedTurns =
2957+ queuedRunCount > 0 ||
2958+ optimisticUserMessages . some (
2959+ ( message ) =>
2960+ message . inputIntent === "queued_turn" &&
2961+ ! serverProjection ?. messages . some ( ( committed ) => committed . id === message . id ) ,
2962+ ) ;
2963+ const scrollToEndSummary =
2964+ [
2965+ childInputNow === null ? null : "Subagent waiting" ,
2966+ queuedRunCount === 0 ? null : `${ queuedRunCount } queued` ,
2967+ ]
2968+ . filter ( ( part ) => part !== null )
2969+ . join ( " · " ) || null ;
2970+ // The end of the timeline holds the thread's present and next turns: what it is doing now,
2971+ // its lifecycle status, guest messages awaiting you, then your queued messages.
2972+ const timelineFooter =
2973+ pendingHandoff === null &&
2974+ ! hasThreadNow &&
2975+ threadStatusLine === null &&
2976+ pendingGuestMessageCount === 0 &&
2977+ ! hasQueuedTurns ? null : (
2978+ < >
2979+ { pendingHandoff === null ? null : (
2980+ < div className = "chat-content-lane" >
2981+ < HandoffRow
2982+ environmentId = { environmentId }
2983+ endpoints = { pendingHandoff }
2984+ providers = { providerStatuses }
2985+ pending
2986+ />
2987+ </ div >
2988+ ) }
2989+ < ThreadNow
2990+ key = { routeThreadKey }
2991+ attention = { childInputNow }
2992+ work = { backgroundWorkNow }
2993+ syncPhase = { shownThreadSyncPhase }
2994+ goal = { goalNow }
2995+ />
2996+ { threadStatusLine }
2997+ { pendingGuestMessageCount > 0 && isServerThread && activeThread ? (
2998+ < GuestPendingMessages
2999+ threadRef = { scopeThreadRef ( activeThread . environmentId , activeThread . id ) }
3000+ onImageExpand = { onExpandTimelineImageStable }
3001+ onFileOpen = { openFileAttachmentStable }
3002+ />
3003+ ) : null }
3004+ { hasQueuedTurns ? queuedRunsControl : null }
3005+ </ >
3006+ ) ;
29823007
29833008 const pendingSidebarFileDrops = useSidebarPendingFileDropStore ( ( state ) => state . pending ) ;
29843009 const consumePendingFileDrop = useSidebarPendingFileDropStore (
@@ -3265,33 +3290,7 @@ export default function ChatView(props: ChatViewProps) {
32653290 : { } ) }
32663291 { ...( onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : { } ) }
32673292 isPreparingWorktree = { ! paintOnlyDisplayedTimeline && isPreparingWorktree }
3268- footer = {
3269- paintOnlyDisplayedTimeline ||
3270- ( pendingHandoff === null &&
3271- pendingGuestMessageCount === 0 &&
3272- threadStatusLine === null ) ? null : (
3273- < >
3274- { pendingHandoff === null ? null : (
3275- < div className = "chat-content-lane" >
3276- < HandoffRow
3277- environmentId = { environmentId }
3278- endpoints = { pendingHandoff }
3279- providers = { providerStatuses }
3280- pending
3281- />
3282- </ div >
3283- ) }
3284- { pendingGuestMessageCount > 0 && isServerThread && activeThread ? (
3285- < GuestPendingMessages
3286- threadRef = { scopeThreadRef ( activeThread . environmentId , activeThread . id ) }
3287- onImageExpand = { onExpandTimelineImageStable }
3288- onFileOpen = { openFileAttachmentStable }
3289- />
3290- ) : null }
3291- { threadStatusLine }
3292- </ >
3293- )
3294- }
3293+ footer = { paintOnlyDisplayedTimeline ? null : timelineFooter }
32953294 listRef = { legendListRef }
32963295 runs = { paintOnlyDisplayedTimeline ? [ ] : ( serverProjection ?. runs ?? [ ] ) }
32973296 latestRun = { paintOnlyDisplayedTimeline ? null : activeActivityRun }
@@ -3367,6 +3366,7 @@ export default function ChatView(props: ChatViewProps) {
33673366 onContentOverflowChange = { setTimelineOverflows }
33683367 onToolOutputCollapsedAtEnd = { onToolOutputCollapsedAtEndStable }
33693368 onViewportAttach = { attachTimelineViewport }
3369+ onFooterGrow = { onTimelineFooterGrowStable }
33703370 hideEmptyPlaceholder = { isDraftHeroState || threadDetailLoading }
33713371 emptyPlaceholderInset = { composerReservedHeight }
33723372 topFadeEnabled = { ! hasTimelineTopBanner }
@@ -3378,6 +3378,7 @@ export default function ChatView(props: ChatViewProps) {
33783378 { showScrollToBottom && ! isThreadFindActive && (
33793379 < ScrollToEndButton
33803380 bottom = { scrollToEndClearance + 4 }
3381+ summary = { scrollToEndSummary }
33813382 onScrollToEnd = { ( ) => {
33823383 composerRef . current ?. restoreAfterTimelineReachedEnd ( ) ;
33833384 scrollToEnd ( true ) ;
@@ -3521,7 +3522,6 @@ export default function ChatView(props: ChatViewProps) {
35213522 : projectCloneSendBlockReason
35223523 }
35233524 isPreparingWorktree = { isPreparingWorktree && ! sendQueuesBehindSetup }
3524- queuedRunsControl = { queuedRunsControl }
35253525 bannerItems = { composerBannerItems }
35263526 resumeCompactionTokens = { resumeCompactionTokens }
35273527 keepFullHistory = { keepFullHistory }
@@ -3559,8 +3559,6 @@ export default function ChatView(props: ChatViewProps) {
35593559 activeThreadModelSelection = { activeThread ?. modelSelection }
35603560 activeProjectId = { activeProject ?. id ?? null }
35613561 activeContextWindow = { activeContextWindow }
3562- activeTasksProgress = { activeComposerTasksProgress }
3563- activeTaskSteps = { activeComposerTaskSteps }
35643562 compactThreadUnavailable = { compactThreadUnavailable }
35653563 compactDisabled = { compactDisabled }
35663564 compactDisabledReason = { compactDisabledReason }
0 commit comments