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.235"
VERSION = "0.261.236"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
125 changes: 116 additions & 9 deletions application/v2_ui/src/components/chat/ConversationRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
//
// The bulk bar appears only while something is selected, in the slot the old "Select" button
// used to occupy permanently.
//
// The list has no scrollbar of its own. It scrolls with the rail around it, so reading down
// it carries the navigation above out of view, and the search (with the bulk bar under it)

Check warning on line 16 in application/v2_ui/src/components/chat/ConversationRail.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
// is held at the top of the rail's scroll region instead of scrolling away.

import { useEffect, useMemo, useRef, useState } from 'react';
import { clsx } from 'clsx';
Expand Down Expand Up @@ -49,9 +53,12 @@
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { Skeleton } from '../ui/primitives';
import { ConversationExportDialog } from './ConversationExportDialog';
import type { ReactNode } from 'react';
import type { FocusEvent, ReactNode, RefObject } from 'react';
import type { Conversation } from '../../lib/types';

/** Space left between the held search and a row control focus has been moved out from under it. */

Check warning on line 59 in application/v2_ui/src/components/chat/ConversationRail.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
const FOCUS_CLEARANCE_PX = 8;

/**
* Tag colours, matching the badges shown beside an open conversation's title so the list
* and the header describe a conversation the same way.
Expand Down Expand Up @@ -451,7 +458,12 @@
);
}

export function ConversationRail() {
export function ConversationRail({

Check warning on line 461 in application/v2_ui/src/components/chat/ConversationRail.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.
scrollRootRef,
}: {
/** The rail's scroll region, which this list scrolls with rather than on its own. */
scrollRootRef: RefObject<HTMLElement>;
}) {
const {
conversations,
conversationsLoading,
Expand All @@ -472,6 +484,8 @@

const sentinelRef = useRef<HTMLDivElement>(null);
const selectAllRef = useRef<HTMLInputElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const stuckMarkerRef = useRef<HTMLDivElement>(null);

/**
* The export in flight, or null.
Expand Down Expand Up @@ -534,7 +548,10 @@
}, []);

// Paging is driven by an IntersectionObserver rather than a scroll handler so it does
// not fire on every scroll frame.
// not fire on every scroll frame. It watches the rail's scroll region rather than the
// viewport: measured against the viewport, the region's edge clipped the end of the list
// before the look-ahead margin could reach it, so the next page waited until the last
// row was already on screen.
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasMore) {
Expand All @@ -547,12 +564,74 @@
void loadMore();
}
},
{ rootMargin: '120px' },
{ root: scrollRootRef.current, rootMargin: '120px' },
);

observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, loadMore]);
}, [hasMore, loadMore, scrollRootRef]);

/**
* Mark the search header while it is stuck to the top of the rail.

Check warning on line 575 in application/v2_ui/src/components/chat/ConversationRail.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
*
* Stuck, the header needs a solid backing so rows passing beneath it do not show through

Check warning on line 577 in application/v2_ui/src/components/chat/ConversationRail.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.
* the glass, and an edge where the list starts. At rest it stays transparent, so the rail
* looks as it always has. The marker is written to the node rather than held in state:
* state would re-render every row each time the header crossed the threshold.
*
* "Stuck" rather than "pinned", which in this list means a pinned conversation.

Check warning on line 582 in application/v2_ui/src/components/chat/ConversationRail.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.
*/
useEffect(() => {
const marker = stuckMarkerRef.current;
const header = headerRef.current;
const root = scrollRootRef.current;
if (!marker || !header || !root) {
return;
}

const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry) {
return;
}
const rootTop = entry.rootBounds?.top ?? root.getBoundingClientRect().top;
// Out of view because it scrolled up past the region's top, not because the
// whole list sits below a short rail.
const stuck = !entry.isIntersecting && entry.boundingClientRect.top < rootTop;
header.toggleAttribute('data-stuck', stuck);
},
{ root },
);

observer.observe(marker);
return () => observer.disconnect();
}, [scrollRootRef]);

/**
* Keep keyboard focus clear of the held header (WCAG 2.4.11).
*
* A row control reached with Tab or Shift+Tab that sits under the held search is moved

Check warning on line 614 in application/v2_ui/src/components/chat/ConversationRail.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
* just below it. CSS cannot do this here: Chromium ignores a control's scroll-margin when
* the control is already inside the scroll region, and scroll-padding on the region also
* applies to the held search box, so focusing the search threw the list hundreds of pixels.

Check warning on line 617 in application/v2_ui/src/components/chat/ConversationRail.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
*
* Only keyboard focus is moved. A click lands on the part of the row the reader can see,
* and scrolling the row out from under the pointer between press and release would leave
* the release on another row and swallow the click.
*/
const keepFocusClearOfHeader = (event: FocusEvent<HTMLElement>) => {
const header = headerRef.current;
const root = scrollRootRef.current;
const target = event.target;
if (!header || !root || !(target instanceof HTMLElement) || !target.matches(':focus-visible')) {
return;
}
const covered = header.getBoundingClientRect().bottom - target.getBoundingClientRect().top;
if (covered > 0) {
root.scrollTop -= covered + FOCUS_CLEARANCE_PX;
}
};

const confirmRemoval = () => {
if (!pendingRemoval) {
Expand All @@ -578,7 +657,14 @@

return (
<div
className="flex h-full min-h-0 flex-col"
className={clsx(
'relative mt-4 border-t border-edge',
// While a search is narrowing the list, the section keeps at least the rail's

Check warning on line 662 in application/v2_ui/src/components/chat/ConversationRail.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
// height. Otherwise a short result list would shrink the scroll range, drag the
// navigation back into view and pull the search box out from under the

Check warning on line 664 in application/v2_ui/src/components/chat/ConversationRail.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
// reader's cursor as they type.
searchTerm && 'min-h-full',
)}
onKeyDown={(event) => {
// A dialog opened from the rail portals out of it, but focus stays on the
// control that opened it — so without this guard, Escape would dismiss the
Expand All @@ -603,7 +689,24 @@
}
}}
>
<div className="px-3 pb-2">
{/* Where the header sits at rest. Once this has scrolled up past the top of the
rail, the header is being held there. */}
<div
ref={stuckMarkerRef}
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-px"
/>

{/* Holds to the top of the rail's scroll region, right under New chat, so search
and the bulk bar stay in reach however far down the list the reader is. */}
<div
ref={headerRef}
data-conversation-rail-header=""
className={clsx(
'sticky top-0 z-10 px-3 pt-3 pb-2',
'data-stuck:bg-surface-solid data-stuck:shadow-[0_1px_0_var(--edge-strong)]',
)}
>
<div className="relative">
<Search
size={14}
Expand Down Expand Up @@ -698,7 +801,7 @@
</span>
</div>

<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-2">
<div className="px-3 pb-2">
{conversationsError && (
<p className="px-1 py-2 text-xs text-danger">{conversationsError}</p>
)}
Expand All @@ -717,7 +820,11 @@
</p>
)}

<ul className="space-y-0.5">
{/* Each row control is a stop in the tab order, and the browser only brings one
into view when it lies outside the rail's scroll region. A row just under the
held search lies inside it, so Shift+Tab can otherwise land on a control the
header covers (WCAG 2.4.11). */}
<ul className="space-y-0.5" onFocus={keepFocusClearOfHeader}>
{conversations.map((conversation) => (
<ConversationRow
key={conversation.id}
Expand Down
16 changes: 13 additions & 3 deletions application/v2_ui/src/components/layout/NotificationBell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
// V2 has no top bar, so the bell sits in the rail's header beside the brand mark. Expanded,
// it shows how many notices are unread; collapsed to the icon strip there is no room for a
// number, so a dot says there is something to read and the accessible name says how much.
//
// A workflow alert that pops up hangs from the bell as well, so it arrives where every notice
// can be found again and tucks back into the thing it came out of. The bell's wrapper is the
// notice's positioning anchor, and the notice follows the bell in the tab order.

import { useCallback, useId, useRef, useState } from 'react';
import { clsx } from 'clsx';
Expand All @@ -12,9 +16,15 @@ import { formatNotificationCount } from '../../lib/notifications';
import { useReducedMotion } from '../../lib/workflowAlertMotion';
import { useNotificationStore } from '../../stores/notificationStore';
import { useWorkflowAlertStore } from '../../stores/workflowAlertStore';
import { WorkflowAlertCalloutSlot } from '../notifications/WorkflowAlertNotice';
import { NotificationPanel } from './NotificationPanel';
import '../notifications/WorkflowAlertNotice.css';

/**
* `className` lays out the bell's wrapper in the rail. The workflow alert notice is positioned
* from that wrapper, so in the collapsed strip it spans the strip's inner width: the notice
* then flies out from the strip's edge rather than from inside it.
*/
export function NotificationBell({ collapsed, className }: { collapsed: boolean; className?: string }) {
const count = useNotificationStore((state) => state.count);
// Bumped when a workflow alert's notice tucks in here; the bell swings once in answer.
Expand All @@ -37,7 +47,7 @@ export function NotificationBell({ collapsed, className }: { collapsed: boolean;
}, []);

return (
<>
<div className={clsx('relative flex shrink-0', className)}>
<button
ref={buttonRef}
type="button"
Expand All @@ -52,7 +62,6 @@ export function NotificationBell({ collapsed, className }: { collapsed: boolean;
className={clsx(
'relative shrink-0 rounded-lg p-1.5 text-text-3 transition-colors hover:bg-surface-2 hover:text-text-1',
open && 'bg-surface-2 text-text-1',
className,
)}
>
<span
Expand Down Expand Up @@ -80,7 +89,8 @@ export function NotificationBell({ collapsed, className }: { collapsed: boolean;
</span>
)}
</button>
<WorkflowAlertCalloutSlot collapsed={collapsed} />
{open && <NotificationPanel id={panelId} anchorRef={buttonRef} onClose={close} />}
</>
</div>
);
}
Loading
Loading