diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 1376a4319ae58..3f25b67e8954d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -2060,6 +2060,22 @@ describe("MessagesTimeline work rows", () => { await using screen = await renderElement(); expect(screen.text()).toContain("Thinking"); + await screen.rerender( + , + ); + expect(screen.text()).not.toContain("Thinking"); + await screen.rerender( , ); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index ef413b376e634..4a32acb3d6243 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -970,7 +970,7 @@ describe("deriveMessagesTimelineRows", () => { const rows = applyWorkingWindow(deriveMessagesTimelineRows(running), running); expect(rows.map((row) => row.id)).toEqual(["prompt", "working-indicator-row", "reply"]); expect(rows.filter((row) => row.kind === "working")).toMatchObject([ - { createdAt: prompt.createdAt, window: { items: [], answering: streaming } }, + { createdAt: prompt.createdAt, window: { items: [], answering: true } }, ]); expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 699cb2cdcdb06..230f12f40d11a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -2859,7 +2859,7 @@ export function applyWorkingWindow( failedTests: deriveFailedTests(items), retryAttempt: deriveRetryAttempt(items), stopping: interruptRequested && !interruptSettled, - answering: answer?.kind === "text" && answer.message.streaming, + answering: answer?.kind === "text", historyOpen: historyOpenKey === key, }; return [ diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 92cee13bf7d28..10170b253478b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -220,11 +220,6 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = { }, } as const satisfies MaintainScrollAtEndOptions; const EMPTY_RUN_IDS: ReadonlySet = new Set(); -const TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH = { - ...TIMELINE_MAINTAIN_SCROLL_AT_END, - animated: true, -} as const satisfies MaintainScrollAtEndOptions; - const CITED_HISTORY_LOADING: CitedHistory = { _tag: "loading" }; export interface MessagesTimelineHistoryControls { @@ -469,7 +464,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ ); const restoringThreadPosition = positionedThreadKey !== listIdentityKey; const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); - const [settlingListIdentity, setSettlingListIdentity] = useState(listIdentityKey); const [playingAudioRows, setPlayingAudioRows] = useState>( () => new Map(), ); @@ -490,7 +484,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ findOpenAtOpening: findOpen, }); setPositionedThreadKey(null); - setSettlingListIdentity(listIdentityKey); // A viewer pinned in another thread must not keep live follow off here, // nor when that thread comes back. setFullscreenAppRowId(null); @@ -1087,12 +1080,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ }; // Rows added, removed or rewrapped (chat width) change the content size, // which moves the scroll position, the overflow, and the content lane width. - const handleContentSizeChanged = useStableCallback((laidOutData: unknown) => { - // The first laid-out content of a newly shown thread ends its settling; a - // frame the previous thread scheduled laid out the previous thread's rows. - if (laidOutData === rows) { - setSettlingListIdentity((current) => (current === listIdentityKey ? null : current)); - } + const handleContentSizeChanged = useStableCallback(() => { handleScroll(); }); // The content lane only changes width with the viewport or the chat width @@ -1272,7 +1260,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ scheduledData = laidOutData; contentSizeFrame = requestAnimationFrame(() => { contentSizeFrame = null; - handleContentSizeChanged(laidOutData); + handleContentSizeChanged(); }); }; const stopListeningToContentSize = list @@ -1473,9 +1461,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ workingHistoryOpen || workingHistoryCollapsing ? false - : isWorking && !prefersReducedMotion && settlingListIdentity === null - ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH - : TIMELINE_MAINTAIN_SCROLL_AT_END + : TIMELINE_MAINTAIN_SCROLL_AT_END } maintainVisibleContentPosition={ findActive || diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx index 0f95ca2cc2677..aa6f1c6e1d8f0 100644 --- a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx @@ -35,11 +35,7 @@ import { } from "./TimelineWorkEntryRow"; import { formatWorkingTimer, formatWorkingTimerNow } from "./TimelineWorkGroupRows.logic"; import { WorkLogButton, WorkLogList, WorkLogRow, WorkLogTarget, WorkLogVerb } from "./WorkLog"; -import { - type WorktreeSetupSnapshot, - type ToolActivityIcon, - type OrchestrationV2TurnItem, -} from "@t3tools/contracts"; +import { type WorktreeSetupSnapshot, type ToolActivityIcon } from "@t3tools/contracts"; import { liveThoughtLine } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; import { observeResize } from "~/lib/observeResize"; @@ -970,7 +966,7 @@ function WorkingWindowView({ !turnEnded && !pendingRequest && !window.answering && - !workingWindowItemIsLive(window.items.at(-1)) && + window.items.length === 0 && !isCompacting && !isPreparingWorktree && !workingWindowHasActiveCompaction(window); @@ -983,7 +979,9 @@ function WorkingWindowView({ aria-label="Work history" className={cn( // The gap under the bar sits inside the window, so its fade reaches the hairline. - "flex min-w-0 flex-col justify-end gap-1 overflow-clip pt-2 transition-[height] duration-200 ease-out motion-reduce:transition-none", + "flex min-w-0 flex-col justify-end gap-1 overflow-clip pt-2", + history !== null && + "transition-[height] duration-200 ease-out motion-reduce:transition-none", open ? "[height:calc-size(auto,size)]" : "[height:calc-size(auto,min(size,220px))] not-supports-[height:calc-size(auto,size)]:max-h-[220px]", @@ -1152,15 +1150,6 @@ function CollapsedWorkingWindow({ ); } -function workingWindowItemIsLive(item: WorkingWindowItem | undefined): boolean { - if (item === undefined) return false; - if (item.kind === "work") return item.entry.toolLifecycleStatus === "inProgress"; - if (item.kind === "text") return Boolean(item.message.streaming); - return (item.row.subagents ?? [item.row.projectedItem]).some(({ item: member }) => - subagentItemIsLive(member.status), - ); -} - const WORKING_WINDOW_TAIL = 12; const WORKING_WINDOW_COLLAPSED_HEIGHT = 220; @@ -1243,10 +1232,6 @@ function WorkingBarSlot({ ) : null; } -function subagentItemIsLive(status: OrchestrationV2TurnItem["status"]): boolean { - return status === "pending" || status === "running" || status === "waiting"; -} - function workingWindowHasActiveCompaction(window: WorkingWindow | undefined): boolean { return ( window?.items.some( diff --git a/apps/web/src/components/chat/timelineViewport.test.ts b/apps/web/src/components/chat/timelineViewport.test.ts index f5d6264053c54..05cff16b7e2b9 100644 --- a/apps/web/src/components/chat/timelineViewport.test.ts +++ b/apps/web/src/components/chat/timelineViewport.test.ts @@ -81,13 +81,13 @@ describe("navigationReleasesFollow", () => { ).toBe(false); }); - it("releases on downward keys, touch and row clicks only away from the end", () => { + it("releases on downward keys and touch only away from the end", () => { const down = { source: "key", key: "PageDown", direction: "down" } as const; expect(navigationReleasesFollow(down, viewport(true, false))).toBe(false); expect(navigationReleasesFollow(down, viewport(false, true))).toBe(true); expect(navigationReleasesFollow({ source: "touch" }, viewport(true, false))).toBe(false); expect( - navigationReleasesFollow({ source: "pointer", onScrollbar: false }, viewport(true, false)), + navigationReleasesFollow({ source: "pointer", onScrollbar: false }, viewport(true, true)), ).toBe(false); expect( navigationReleasesFollow({ source: "pointer", onScrollbar: true }, viewport(true, false)), @@ -98,6 +98,6 @@ describe("navigationReleasesFollow", () => { expect( navigationReleasesFollow({ source: "command", releasesFollow: false }, viewport(true, true)), ).toBe(false); - expect(navigationReleasesFollow({ source: "scroll" }, viewport(false, false))).toBe(true); + expect(navigationReleasesFollow({ source: "scroll" }, viewport(false, false))).toBe(false); }); }); diff --git a/apps/web/src/components/chat/timelineViewport.ts b/apps/web/src/components/chat/timelineViewport.ts index 23b3e77695e56..cd799a405b253 100644 --- a/apps/web/src/components/chat/timelineViewport.ts +++ b/apps/web/src/components/chat/timelineViewport.ts @@ -191,13 +191,13 @@ export function navigationReleasesFollow( case "key": return navigation.direction === "up" ? viewport.contentScrollsUp() : viewport.awayFromEnd(); case "pointer": - return navigation.onScrollbar ? viewport.contentScrollsUp() : viewport.awayFromEnd(); + return navigation.onScrollbar && viewport.contentScrollsUp(); case "touch": // Touch direction is not observable; an upward flick leaves the end // band within its first few events. return viewport.awayFromEnd(); case "scroll": - return true; + return false; case "command": return navigation.releasesFollow; }