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
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.317"
VERSION = "0.261.318"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
125 changes: 50 additions & 75 deletions application/v2_ui/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import { WorkflowDeliveryFooter } from './WorkflowDeliveryFooter';
import { MessageInspector, type InspectorSection } from './MessageInspector';
import { ThoughtsList, ThoughtsProgressCard } from './ThoughtsList';
import { useMessageScroll } from './useMessageScroll';
import { OrchestrationPlanCard } from './OrchestrationPlanCard';
import { ElicitationCard } from './ElicitationCard';
import {
Expand Down Expand Up @@ -1354,7 +1355,7 @@
}

return (
<div className="flex justify-start">
<div data-scroll-live-reply className="flex justify-start">
<div className={clsx('glass-flat rounded-2xl px-4 py-3', bubbleWidthClass(chatWidth))}>
{/* Shown while connecting even when partial content is already on screen:
without it, a response that stopped mid-sentence just looks frozen. */}
Expand Down Expand Up @@ -1532,78 +1533,12 @@
streamError,
streamAuthUrl,
activeConversationId,
completedReply,
} = useChatStore();
const safeStreamAuthUrl = normalizeFoundryAuthUrl(streamAuthUrl);
const appTitle = useBootstrapStore((state) => state.data?.branding?.app_title);
const chatWidth = useUiStore((state) => state.chatWidth);

const scrollRef = useRef<HTMLDivElement>(null);

/**
* Whether the reader is at the bottom of the thread.
*
* A ref rather than state on purpose. Held in state, every scroll that crossed the
* threshold re-rendered the list and with it every message's markdown, which is expensive
* enough to stall a thread containing a large diagram. Nothing on screen depends on it, so
* nothing needs to re-render when it changes.
*/
const pinnedRef = useRef(true);

const scrollToBottom = useCallback(() => {
const element = scrollRef.current;
if (element) {
// Set directly rather than through `scrollIntoView`, which also scrolls every
// scrollable ancestor and can drag the page itself around.
element.scrollTop = element.scrollHeight;
}
}, []);

// Auto-scroll only while the user is already at the bottom, so reading back through a
// long answer is not interrupted by incoming tokens.
useEffect(() => {
if (pinnedRef.current) {
scrollToBottom();
}
}, [messages, streamingContent, scrollToBottom]);

/**
* Follow content that grows after it was laid out.
*
* A diagram renders asynchronously: a 96px placeholder is replaced by a panel that can be
* several hundred pixels tall, long after the scroll that was meant to land at the bottom.
* Nothing re-ran, so the reader was left above the end of the thread, chasing a target that
* moved every time another diagram finished.
*/
useEffect(() => {
const element = scrollRef.current;
if (!element || typeof ResizeObserver === 'undefined') {
return;
}
// The content, not the viewport: the viewport's own size changing is a window resize,
// which should not yank the reader to the bottom.
const content = element.firstElementChild;
if (!content) {
return;
}
const observer = new ResizeObserver(() => {
if (pinnedRef.current) {
scrollToBottom();
}
});
observer.observe(content);
return () => observer.disconnect();
}, [scrollToBottom]);

const onScroll = () => {
const element = scrollRef.current;
if (!element) {
return;
}
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
pinnedRef.current = distanceFromBottom < 80;
};

const isEmpty = useMemo(
() => messages.length === 0 && !streaming && !messagesLoading,
[messages.length, streaming, messagesLoading],
Expand Down Expand Up @@ -1659,10 +1594,27 @@
return { threadMessages: visible, proposalImagesByMessage: grouped };
}, [messages]);

return (
const {
scrollRef, hasNewMessages, onScroll, onWheel, onKeyDown, onTouchStart, onPointerDown, jumpToNewest, pauseFollowing,
} = useMessageScroll({
conversationId: activeConversationId,

Check warning on line 1600 in application/v2_ui/src/components/chat/MessageList.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
messages: threadMessages,
loading: messagesLoading,
streaming,
content: streamingContent,
completedReply,
});

const messagePane = (
<div
ref={scrollRef}
onScroll={onScroll}
onWheel={onWheel}
onKeyDown={onKeyDown}
onTouchStart={onTouchStart}
onPointerDown={onPointerDown}
tabIndex={0}
aria-label="Conversation messages"

Check warning on line 1617 in application/v2_ui/src/components/chat/MessageList.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
className="min-h-0 flex-1 overflow-y-auto px-4 py-6"
data-tour="message-list"
>
Expand Down Expand Up @@ -1696,14 +1648,15 @@
{/* Streaming output is announced politely so screen reader users are told
a response arrived without every token interrupting them. */}
<PendingActionPlacementProvider messages={threadMessages}>
<PendingActionsConversationSection scrollRef={scrollRef} pinnedRef={pinnedRef} />
<PendingActionsConversationSection scrollRef={scrollRef} pauseFollowing={pauseFollowing} />

Check warning on line 1651 in application/v2_ui/src/components/chat/MessageList.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
<div aria-live="polite" aria-atomic="false" className="space-y-4">
{threadMessages.map((message) => (
<MessageBubble
key={message.id}
message={message}
proposalImages={proposalImagesByMessage.get(message.id)}
/>
<div key={message.id} data-scroll-message-id={message.id}>
<MessageBubble
message={message}
proposalImages={proposalImagesByMessage.get(message.id)}
/>
</div>
))}
{streaming && <StreamingBubble />}
<StreamingPendingActions />
Expand Down Expand Up @@ -1739,4 +1692,26 @@
</div>
</div>
);

return (
<div className="relative flex min-h-0 flex-1 flex-col">
{messagePane}
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">

Check warning on line 1699 in application/v2_ui/src/components/chat/MessageList.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
{hasNewMessages ? 'New messages below.' : ''}
</span>
{hasNewMessages && (
<div className="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center px-4">
<button
type="button"
onClick={jumpToNewest}
aria-label="Go to the beginning of the newest message"
className="pointer-events-auto inline-flex min-h-10 items-center gap-2 rounded-full border border-edge-strong bg-surface-solid px-4 py-2 text-sm font-medium text-text-1 shadow-lg transition-colors hover:bg-surface-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
New messages
<ChevronDown size={16} aria-hidden="true" />

Check warning on line 1711 in application/v2_ui/src/components/chat/MessageList.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Moderate - Changed line contains obfuscation, dynamic loading, or hidden payload marker. Recommendation%3A Confirm the changed code is not hiding behavior, decoding payloads, or bypassing normal review.
</button>
</div>
)}
</div>
);
}
12 changes: 6 additions & 6 deletions application/v2_ui/src/components/chat/PendingActionSlots.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@
* moment and focused, and the request is spent. If the card is not in the list it is fetched by
* id; only when that also fails does the person hear that it is unavailable.
*/
function usePendingActionFocus(scrollRef: RefObject<HTMLElement>, pinnedRef: { current: boolean }) {
function usePendingActionFocus(scrollRef: RefObject<HTMLElement>, pauseFollowing: () => void) {
const api = usePendingActionsStoreApi();
const request = usePendingActions((state) => state.focusRequest);
const conversationId = usePendingActions((state) => state.conversationId);
Expand Down Expand Up @@ -207,7 +207,7 @@

if (node) {
// Leave auto-scroll alone: it would otherwise pull the thread back to the bottom.
pinnedRef.current = false;
pauseFollowing();
const rootBox = root.getBoundingClientRect();
const nodeBox = node.getBoundingClientRect();
const margin = Math.max(16, (root.clientHeight - nodeBox.height) / 2);
Expand Down Expand Up @@ -246,7 +246,7 @@
setUnavailableFor(state.conversationId);
state.clearFocus();
});
}, [api, conversationId, listStatus, messagesLoading, pinnedRef, placement, present, referenceLoading, request, scrollRef]);
}, [api, conversationId, listStatus, messagesLoading, pauseFollowing, placement, present, referenceLoading, request, scrollRef]);

Check warning on line 249 in application/v2_ui/src/components/chat/PendingActionSlots.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.

// Only on unmount: clearing the focus request re-runs the effect above, which must not
// cancel the highlight it just applied.
Expand All @@ -266,10 +266,10 @@
*/
export function PendingActionsConversationSection({
scrollRef,
pinnedRef,
pauseFollowing,
}: {
scrollRef: RefObject<HTMLElement>;
pinnedRef: { current: boolean };
pauseFollowing: () => void;
}) {
const api = usePendingActionsStoreApi();
const headingId = useId();
Expand All @@ -283,7 +283,7 @@
const listLoading = usePendingActions((state) => state.listStatus === 'loading');

usePendingActionsLifecycle(activeConversationId);
const { unavailable, dismissUnavailable } = usePendingActionFocus(scrollRef, pinnedRef);
const { unavailable, dismissUnavailable } = usePendingActionFocus(scrollRef, pauseFollowing);

// Held back while history loads, so a card is not shown here for an instant and then jump
// under its message.
Expand Down
Loading
Loading