Skip to content
Open
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
1 change: 1 addition & 0 deletions crates/agent-gateway/web/src/app/GatewayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4900,6 +4900,7 @@ export default function GatewayApp() {
activeTurnKey={displayedTranscript.activeTurnKey}
contentWidth={settings.customSettings.chatTranscript.width}
isViewportFollowing={transcriptFollow.isFollowing}
viewportFollowing={transcriptFollowing}
navRef={transcriptNavRef}
onAnchorUserRowChange={setActiveFloorKey}
error={transcriptError}
Expand Down
80 changes: 23 additions & 57 deletions crates/agent-gateway/web/src/components/GatewayTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { useLocale } from "@/i18n/LocaleContext";
import type { ChatFileLink } from "@/lib/chat/chatFileLinks";
import { normalizeLiveToolStatus, VIBING_STATUS } from "@/lib/chat/chatPageHelpers";
import type { HistoryMessageRef } from "@/lib/chat/conversationState";
import { getRoundText, getRoundToolTrace } from "@/lib/chat/uiMessages";
import { getRoundText } from "@/lib/chat/uiMessages";
import {
formatUploadedFileSize,
type PendingUploadedFile,
Expand Down Expand Up @@ -60,7 +60,6 @@ import {
} from "@/pages/chat/AssistantBubble";
import type { RetryAttemptRecord, TranscriptRow } from "../lib/chat/transcript/types";

import type { GatewayTranscriptRound } from "../lib/chatUi";
import type { SectionId } from "../pages/settings/types";
import { ChatEmptyState } from "./chat/ChatEmptyState";
import { getUploadedFileTypeIcon } from "./chat/fileTypeIcons";
Expand Down Expand Up @@ -92,6 +91,7 @@ type GatewayTranscriptProps = {
// Whether the scroll-follow engine is attached to the bottom; gates the
// virtualizer's resize-compensation carve-out for live-row growth.
isViewportFollowing?: () => boolean;
viewportFollowing?: boolean;
// Imperative jump handle for the floor navigation rail.
navRef?: MutableRefObject<GatewayTranscriptNavHandle | null>;
// Reports the user row at the viewport's top edge (the "current floor").
Expand Down Expand Up @@ -168,50 +168,18 @@ function resolveNearestScrollViewport(element: HTMLElement | null) {
function LiveStatusFooter(props: { status: string; isCompaction?: boolean }) {
const { status, isCompaction = false } = props;
return (
<div className="gateway-live-status-footer ml-9 pt-1">
<div className="gateway-live-status-footer ml-9 min-w-0 overflow-hidden pt-1">
{isCompaction ? (
<CompactingText />
<CompactingText className="w-full" />
) : status === VIBING_STATUS ? (
<VibingText />
<VibingText className="w-full" />
) : (
<AssistantStatus>{status}</AssistantStatus>
<AssistantStatus className="w-full">{status}</AssistantStatus>
)}
</div>
);
}

function shouldShowLiveStatusForRounds(rounds: GatewayTranscriptRound[]) {
const activeRound = rounds[rounds.length - 1];
if (!activeRound) {
return true;
}
const visibleToolKeys = new Set(
getRoundToolTrace(activeRound).map((item) => `${item.toolCall.id}\u0000${item.toolCall.name}`),
);

for (let index = activeRound.blocks.length - 1; index >= 0; index -= 1) {
const block = activeRound.blocks[index];
if (!block) {
continue;
}
if (block.kind === "tool") {
if (visibleToolKeys.has(`${block.item.toolCall.id}\u0000${block.item.toolCall.name}`)) {
return true;
}
continue;
}
if (block.kind === "hostedSearch") {
return false;
}
if (block.text.trim() === "") {
continue;
}
return block.kind !== "text";
}

return true;
}

function HistoryLoadingState(props: { title?: string }) {
const title = props.title?.trim();
return (
Expand Down Expand Up @@ -1184,6 +1152,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
contentWidth: number;
scrollViewport: HTMLDivElement | null;
isViewportFollowing?: () => boolean;
viewportFollowing: boolean;
navRef?: MutableRefObject<GatewayTranscriptNavHandle | null>;
onAnchorUserRowChange?: (rowKey: string | null) => void;
hasMoreHistory?: boolean;
Expand Down Expand Up @@ -1218,6 +1187,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
contentWidth,
scrollViewport,
isViewportFollowing,
viewportFollowing,
navRef,
onAnchorUserRowChange,
hasMoreHistory,
Expand Down Expand Up @@ -1361,25 +1331,19 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
gap: TRANSCRIPT_ROW_GAP,
overscan: TRANSCRIPT_ROW_OVERSCAN_COUNT,
enabled: scrollViewport !== null,
// End-anchored: prepends (loading earlier history) keep the visible item
// stable upstream via keyed anchoring, growth of the last row while the
// viewport is virtually at the end compensates by the total-size delta,
// and estimate→measure corrections keep the bottom pinned. The threshold
// matches scrollFollowCore's BOTTOM_ATTACH_THRESHOLD_PX so both engines
// agree on what "at the bottom" means. followOnAppend stays off: its
// DOM-distance re-follow would conflict with the follow reducer's
// "shrink clamps never re-attach" contract — appends while following are
// already pinned by the reducer.
anchorTo: "end",
// End anchoring is enabled only for a detached reader so keyed prepends
// preserve the visible row. While following, start anchoring disables the
// virtualizer's bottom correction and leaves live growth to useScrollFollow.
anchorTo: viewportFollowing ? "start" : "end",
scrollEndThreshold: 8,
initialMeasurementsCache,
rangeExtractor: (range) => extractLiveRange(range, forceMountStartRef.current),
});

// TanStack exposes the resize-compensation predicate as an instance field,
// not an option; reassigning per render keeps the closure's inputs current.
// It only governs the detached reader — while virtually at the end, the
// upstream end-anchor compensation takes priority over this predicate.
// While following it rejects every virtualizer correction; while detached
// it retains estimate/measurement anchoring for rows above the viewport.
transcriptVirtualizer.shouldAdjustScrollPositionOnItemSizeChange =
createLiveRowScrollAdjustPolicy({
getLiveStartIndex: () => forceMountStartRef.current,
Expand Down Expand Up @@ -1701,16 +1665,11 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
const rowIndex = virtualRow.index - leadingOffset;
const isLatestLiveAssistant = rowIndex === liveAssistantIndex;
const isLatestLiveStreaming = isStreaming && isLatestLiveAssistant;
const shouldShowLiveStatus =
isLatestLiveStreaming &&
Boolean(displayedToolStatus) &&
!displayedToolStatusIsCompaction &&
shouldShowLiveStatusForRounds(row.rounds);
const liveStatusText = shouldShowLiveStatus ? (displayedToolStatus ?? "") : "";
return (
<article
key={virtualRow.key}
data-index={virtualRow.index}
data-row-key={row.key}
ref={transcriptVirtualizer.measureElement}
className="gateway-transcript-row absolute left-0 right-0 top-0"
style={{ transform: `translateY(${virtualRow.start}px)` }}
Expand All @@ -1728,7 +1687,12 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
workdir={workspaceRoot}
onOpenFileLink={onOpenFileLink}
/>
{shouldShowLiveStatus ? <LiveStatusFooter status={liveStatusText} /> : null}
{isLatestLiveStreaming ? (
<LiveStatusFooter
status={displayedToolStatus ?? VIBING_STATUS}
isCompaction={displayedToolStatusIsCompaction}
/>
) : null}
{isLatestLiveStreaming &&
!shouldShowPendingLiveBubble &&
retryAttempts &&
Expand Down Expand Up @@ -1796,6 +1760,7 @@ export function GatewayTranscript({
activeTurnKey = null,
contentWidth = DEFAULT_CHAT_TRANSCRIPT_WIDTH,
isViewportFollowing,
viewportFollowing = false,
navRef,
onAnchorUserRowChange,
error,
Expand Down Expand Up @@ -1884,6 +1849,7 @@ export function GatewayTranscript({
contentWidth={contentWidth}
scrollViewport={transcriptScrollViewport}
isViewportFollowing={isViewportFollowing}
viewportFollowing={viewportFollowing}
navRef={navRef}
onAnchorUserRowChange={onAnchorUserRowChange}
hasMoreHistory={hasMoreHistory}
Expand Down
149 changes: 149 additions & 0 deletions crates/agent-gateway/web/src/components/chat/ThinkingActivity.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { useCallback, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useLocale } from "../../i18n";
import type { ChatFileLink } from "../../lib/chat/chatFileLinks";
import {
resolveThinkingOverlayPlacement,
type ThinkingOverlayPlacement,
} from "../../lib/chat/thinkingOverlayModel";
import { ChevronRight, Lightbulb } from "../icons";
import { Markdown } from "../Markdown";

export function ThinkingActivity(props: {
text: string;
isRunning?: boolean;
renderMode: "streaming" | "static";
workdir?: string;
onOpenFileLink?: (link: ChatFileLink) => void;
}) {
const { text, isRunning = false, renderMode, workdir, onOpenFileLink } = props;
const { t } = useLocale();
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState<ThinkingOverlayPlacement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const panelId = useId();
const hasText = /\S/.test(text);

const updatePlacement = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
setPlacement(
resolveThinkingOverlayPlacement(trigger.getBoundingClientRect(), {
width: window.innerWidth,
height: window.innerHeight,
}),
);
}, []);

const close = useCallback((restoreFocus = false) => {
setOpen(false);
setPlacement(null);
if (restoreFocus) {
requestAnimationFrame(() => triggerRef.current?.focus({ preventScroll: true }));
}
}, []);

useLayoutEffect(() => {
if (!open) return;
updatePlacement();
const focusFrame = requestAnimationFrame(() =>
panelRef.current?.focus({ preventScroll: true }),
);
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) return;
close();
};
const handleFocusIn = (event: FocusEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) return;
close();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
close(true);
};
window.addEventListener("pointerdown", handlePointerDown, true);
window.addEventListener("focusin", handleFocusIn, true);
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("resize", updatePlacement);
window.addEventListener("scroll", updatePlacement, true);
return () => {
cancelAnimationFrame(focusFrame);
window.removeEventListener("pointerdown", handlePointerDown, true);
window.removeEventListener("focusin", handleFocusIn, true);
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("resize", updatePlacement);
window.removeEventListener("scroll", updatePlacement, true);
};
}, [close, open, updatePlacement]);

if (!hasText) return null;

return (
<div className="group/think w-full">
<button
ref={triggerRef}
type="button"
aria-controls={open ? panelId : undefined}
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => {
if (open) close();
else setOpen(true);
}}
className="thinking-block-toggle flex w-full cursor-pointer select-none items-center gap-2 py-1.5 text-left text-[calc(13px*var(--zone-font-scale,1))] font-normal text-muted-foreground/80 hover:text-foreground"
>
{isRunning ? (
<span className="flex items-center gap-2">
<span className="h-3.5 w-3.5 shrink-0 animate-spin rounded-full border-2 border-current border-r-transparent motion-reduce:animate-none" />
{t("chat.thinking")}
</span>
) : (
<>
<Lightbulb className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
<span className="thinking-block-label">{t("chat.thinkingProcess")}</span>
</>
)}
<ChevronRight
className={`ml-auto h-3.5 w-3.5 text-muted-foreground/60 transition-transform duration-200 ease-out motion-reduce:transition-none ${open ? "rotate-90" : ""}`}
/>
</button>
{open && placement
? createPortal(
<div
ref={panelRef}
id={panelId}
role="dialog"
aria-label={t("chat.thinkingProcess")}
tabIndex={-1}
data-scroll-follow-ignore-keys
className="fixed z-[120] overflow-y-auto overscroll-contain rounded-xl border border-border/80 bg-background/95 p-4 shadow-2xl outline-none backdrop-blur-md"
style={{
left: placement.left,
width: placement.width,
maxHeight: placement.maxHeight,
top: placement.top,
bottom: placement.bottom,
}}
>
<Markdown
content={text}
className="thinking-markdown space-y-1.5"
renderMode={renderMode}
showCaret={false}
workdir={workdir}
onOpenFileLink={onOpenFileLink}
/>
</div>,
document.body,
)
: null}
</div>
);
}
31 changes: 31 additions & 0 deletions crates/agent-gateway/web/src/lib/chat-scroll/framePinController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export type ScheduleFrame = (callback: () => void) => number;
export type CancelFrame = (handle: number) => void;

export function createFramePinController(
write: () => void,
scheduleFrame: ScheduleFrame,
cancelFrame: CancelFrame,
) {
let pendingFrame: number | null = null;

const cancel = () => {
if (pendingFrame === null) return;
cancelFrame(pendingFrame);
pendingFrame = null;
};

const schedule = () => {
if (pendingFrame !== null) return;
pendingFrame = scheduleFrame(() => {
pendingFrame = null;
write();
});
};

const flush = () => {
cancel();
write();
};

return { cancel, flush, schedule };
}
Loading
Loading