Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
scheduleEnvironmentReconnectWarning,
startNewThreadForProject,
shouldDockDraftHeroForSubmission,
shouldReleaseTimelineAnchorForToolActivity,
shouldShowBranchMismatchBanner,
shouldWriteThreadErrorToCurrentServerThread,
} from "./ChatView.logic";
Expand Down Expand Up @@ -77,6 +78,114 @@ describe("draft hero submission transition", () => {
});
});

describe("shouldReleaseTimelineAnchorForToolActivity", () => {
const activeTurnId = TurnId.make("active-turn");
const anchorMessageId = MessageId.make("anchored-message");
const activeToolEntry = {
id: "tool-entry",
kind: "work" as const,
createdAt: now,
entry: {
id: "active-tool",
createdAt: now,
turnId: activeTurnId,
label: "Run command",
tone: "tool" as const,
command: "git status",
},
};

it("releases the send anchor for tool activity in the active turn", () => {
expect(
shouldReleaseTimelineAnchorForToolActivity({
anchorMessageId,
liveFollowEnabled: true,
runningTurnId: activeTurnId,
timelineEntries: [activeToolEntry],
}),
).toBe(true);
});

it("keeps the anchor while the user reads history", () => {
expect(
shouldReleaseTimelineAnchorForToolActivity({
anchorMessageId,
liveFollowEnabled: false,
runningTurnId: activeTurnId,
timelineEntries: [activeToolEntry],
}),
).toBe(false);
});

it("ignores tool activity from earlier turns", () => {
expect(
shouldReleaseTimelineAnchorForToolActivity({
anchorMessageId,
liveFollowEnabled: true,
runningTurnId: activeTurnId,
timelineEntries: [
{
...activeToolEntry,
entry: {
...activeToolEntry.entry,
turnId: TurnId.make("previous-turn"),
},
},
],
}),
).toBe(false);
});

it("ignores thinking and error rows without tool activity", () => {
expect(
shouldReleaseTimelineAnchorForToolActivity({
anchorMessageId,
liveFollowEnabled: true,
runningTurnId: activeTurnId,
timelineEntries: [
{
...activeToolEntry,
entry: {
id: "thinking-entry",
createdAt: now,
turnId: activeTurnId,
label: "Thinking",
tone: "thinking",
},
},
{
...activeToolEntry,
id: "error-entry",
entry: {
id: "error-entry",
createdAt: now,
turnId: activeTurnId,
label: "Provider error",
tone: "error",
},
},
],
}),
).toBe(false);
});

it("does nothing without an anchor or running turn", () => {
const input = {
anchorMessageId,
liveFollowEnabled: true,
runningTurnId: activeTurnId,
timelineEntries: [activeToolEntry],
};

expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe(
false,
);
expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe(
false,
);
});
});

describe("environment reconnect warning grace", () => {
afterEach(() => vi.useRealTimers());

Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type EnvironmentId,
isProviderDriverKind,
ProjectId,
type MessageId,
type ModelSelection,
type ProviderDriverKind,
type ServerProvider,
Expand All @@ -22,6 +23,7 @@ import {
} from "../lib/terminalContext";
import type { DraftThreadEnvMode } from "../composerDraftStore";
import type { ComposerSubmissionIntent } from "../composer-logic";
import type { TimelineEntry } from "../session-logic";

export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
Expand All @@ -42,6 +44,31 @@ export function shouldDockDraftHeroForSubmission(input: {
);
}

export function shouldReleaseTimelineAnchorForToolActivity(input: {
anchorMessageId: MessageId | null;
liveFollowEnabled: boolean;
runningTurnId: TurnId | null;
timelineEntries: ReadonlyArray<TimelineEntry>;
}): boolean {
if (input.anchorMessageId === null || !input.liveFollowEnabled || input.runningTurnId === null) {
return false;
}

return input.timelineEntries.some((timelineEntry) => {
if (timelineEntry.kind !== "work" || timelineEntry.entry.turnId !== input.runningTurnId) {
return false;
}

const entry = timelineEntry.entry;
return (
entry.tone === "tool" ||
entry.itemType !== undefined ||
entry.requestKind !== undefined ||
(entry.command?.trim().length ?? 0) > 0
);
});
}

export function resolveDraftHeroState(input: {
isLocalDraftThread: boolean;
hasTimelineEntries: boolean;
Expand Down
34 changes: 29 additions & 5 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ import {
hasServerAcknowledgedLocalDispatch,
isBranchMismatchDismissedForSession,
shouldDockDraftHeroForSubmission,
shouldReleaseTimelineAnchorForToolActivity,
shouldShowBranchMismatchBanner,
getStartedThreadModelChangeBlockReason,
LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
Expand Down Expand Up @@ -1756,6 +1757,9 @@ function ChatViewContent(props: ChatViewProps) {
return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
}, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
const activeLatestTurn = activeThread?.latestTurn ?? null;
const activeRunningTurnId =
(activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null) ??
(activeLatestTurn?.state === "running" ? activeLatestTurn.turnId : null);
// Reading a finished thread clears the sidebar's Done badge. The visit is
// stamped at the turn's completion time — not now/updatedAt — so it clears
// exactly the completion the user is looking at: a wake or completion that
Expand Down Expand Up @@ -3895,6 +3899,8 @@ function ChatViewContent(props: ChatViewProps) {
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
setTimelineLiveFollowEnabled(true);
pendingTimelineAnchorRef.current = null;
positionedTimelineAnchorRef.current = null;
settledTimelineAnchorRef.current = null;
activeTimelineAnchorIndexRef.current = null;
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
Expand All @@ -3903,6 +3909,28 @@ function ChatViewContent(props: ChatViewProps) {
void legendListRef.current?.scrollToEnd?.({ animated });
});
}, []);
useLayoutEffect(() => {
if (timelineScrollModeRef.current !== "anchoring-new-turn") {
return;
}

if (
shouldReleaseTimelineAnchorForToolActivity({
anchorMessageId: timelineAnchorMessageId,
liveFollowEnabled: timelineLiveFollowEnabled,
runningTurnId: activeRunningTurnId,
timelineEntries,
})
) {
scrollToEnd();
}
}, [
activeRunningTurnId,
scrollToEnd,
timelineAnchorMessageId,
timelineEntries,
timelineLiveFollowEnabled,
]);
useEffect(() => {
let removeListeners: (() => void) | null = null;
let frame: number | null = null;
Expand Down Expand Up @@ -6640,11 +6668,7 @@ function ChatViewContent(props: ChatViewProps) {
listRef={legendListRef}
timelineEntries={timelineEntries}
latestTurn={activeLatestTurn}
runningTurnId={
activeThread.session?.status === "running"
? activeThread.session.activeTurnId
: null
}
runningTurnId={activeRunningTurnId}
turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
activeThreadEnvironmentId={activeThread.environmentId}
routeThreadKey={routeThreadKey}
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,49 @@ describe("MessagesTimeline", () => {
expect(onAnchorReady).not.toHaveBeenCalled();
});

it("keeps reserved end space when tool work starts while reading history", () => {
const turnId = TurnId.make("turn-with-active-tool");
const firstEntry = buildUserTimelineEntry("Run the command.");
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
isWorking
activeTurnStartedAt={MESSAGE_CREATED_AT}
latestTurn={{
turnId,
state: "running",
startedAt: MESSAGE_CREATED_AT,
completedAt: null,
}}
runningTurnId={turnId}
anchorMessageId={firstEntry.message.id}
liveFollowEnabled={false}
timelineEntries={[
firstEntry,
{
id: "entry-active-tool",
kind: "work",
createdAt: MESSAGE_CREATED_AT,
entry: {
id: "work-active-tool",
createdAt: MESSAGE_CREATED_AT,
turnId,
toolCallId: "call-active-tool",
label: "Run command",
tone: "tool",
itemType: "command_execution",
command: "git status",
toolLifecycleStatus: "inProgress",
},
},
]}
/>,
);

expect(markup).toContain('data-anchor-index="0"');
expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"');
});

it("hands end-following back to the list once the send anchor is released", () => {
const firstEntry = buildUserTimelineEntry("First prompt.");
const secondEntry = {
Expand Down
Loading