Skip to content

Commit bd3287b

Browse files
t3dotggclaude
andauthored
feat(web): thread state lives at the end of the timeline, not stacked on the composer (#25401)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0623d09 commit bd3287b

34 files changed

Lines changed: 712 additions & 1518 deletions

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 77 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
9093
import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff";
9194
import { HandoffRow } from "./chat/V2LifecycleRow";
9295
import {
@@ -126,8 +129,6 @@ import { useActiveThreadRef } from "../hooks/useActiveThreadRef";
126129
import { composerRequiresModifier } from "../composer-logic";
127130
import {
128131
derivePhase,
129-
deriveActivePlanState,
130-
selectComposerTasksPlan,
131132
deriveActiveWorkStartedAt,
132133
deriveCanInterruptRunningThread,
133134
findLatestProposedPlan,
@@ -352,6 +353,8 @@ import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers";
352353
import { ChatRightPanel } from "./chat/ChatRightPanel";
353354
import { useDraftEnvironmentRouting } from "./chat/useDraftEnvironmentRouting";
354355
import { branchMismatchBannerItem } from "./chat/ThreadActivityBanners";
356+
import { ThreadNow } from "./chat/ThreadNow";
357+
import { useDelayedStatus } from "../hooks/useDelayedStatus";
355358
import { useTimelineFollow } from "./chat/useTimelineFollow";
356359
import {
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

Comments
 (0)