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;
}