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 @@ -103,7 +103,7 @@
EXECUTOR_MAX_WORKERS = 30
CHAT_STREAM_EXECUTOR_MAX_WORKERS = max(8, int(os.getenv('SIMPLECHAT_CHAT_STREAM_WORKERS', '32') or '32'))
SESSION_TYPE = 'filesystem'
VERSION = "0.261.405"
VERSION = "0.261.406"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
225 changes: 121 additions & 104 deletions application/v2_ui/src/components/chat/ConversationRail.tsx

Large diffs are not rendered by default.

76 changes: 67 additions & 9 deletions application/v2_ui/src/components/ui/ActionPopover.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,82 @@
// ActionPopover.tsx
// A bounded overlay for header actions; its host stays outside clipped page panes.
// A bounded action overlay; its host stays outside clipped page panes.

import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from 'react';
import { createPortal } from 'react-dom';
import { clsx } from 'clsx';
import { dropdownPlacement } from './dropdownPlacement';

export function ActionPopover({
id, label, anchorRef, onClose, children, inline = false,
placement = 'below', boundaryRef, className,
}: {
id: string;
label: string;
anchorRef: RefObject<HTMLButtonElement>;
onClose: (returnFocus: boolean) => void;
children: ReactNode;
inline?: boolean;
placement?: 'below' | 'auto';
boundaryRef?: RefObject<HTMLElement>;
className?: string;
}) {
const panelRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ top: 0, left: 8, maxHeight: 320, width: 272 });
const [position, setPosition] = useState<CSSProperties>({
top: 0, left: 8, maxHeight: 320, width: placement === 'auto' ? undefined : 272,
});

useLayoutEffect(() => {
if (inline) return;
const measure = () => {
const anchor = anchorRef.current;
if (!anchor) return;
const panel = panelRef.current;
if (!anchor || !panel) return false;
const rect = anchor.getBoundingClientRect();
if (placement === 'auto') {
const visual = window.visualViewport;
const viewport = {
left: visual?.offsetLeft ?? 0,
top: visual?.offsetTop ?? 0,
width: visual?.width ?? window.innerWidth,
height: visual?.height ?? window.innerHeight,
};
const boundary = boundaryRef?.current?.getBoundingClientRect();
if (!anchor.isConnected || !anchor.getClientRects().length
|| rect.bottom <= viewport.top || rect.top >= viewport.top + viewport.height
|| rect.right <= viewport.left || rect.left >= viewport.left + viewport.width
|| (boundary && (
rect.bottom <= boundary.top || rect.top >= boundary.bottom
|| rect.right <= boundary.left || rect.left >= boundary.right
))) {
onClose(false);
return false;
}
const margin = Math.min(12, viewport.width / 2, viewport.height / 2);
const maxWidth = Math.max(0, viewport.width - margin * 2);
const placed = dropdownPlacement(rect, viewport, 'right', {
width: Math.min(panel.getBoundingClientRect().width, maxWidth),
height: panel.scrollHeight + panel.offsetHeight - panel.clientHeight,
});
setPosition({
top: placed.top, left: placed.left, maxHeight: placed.maxHeight, maxWidth,
transform: placed.up ? 'translateY(-100%)' : undefined,
});
return true;
}
const width = Math.min(272, window.innerWidth - 16);
const top = Math.min(rect.bottom + 8, window.innerHeight - 52);
setPosition({
top, width,
left: Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8)),
maxHeight: Math.max(44, window.innerHeight - top - 8),
});
return true;
};
measure();
if (!measure()) return;
const first = Array.from(panelRef.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled), a[href]',
) ?? []).find((control) => control.getClientRects().length > 0);
(first ?? panelRef.current)?.focus();
(first ?? panelRef.current)?.focus({ preventScroll: placement === 'auto' });
const outside = (event: PointerEvent) => {
if (event.target instanceof Node &&
!panelRef.current?.contains(event.target) && !anchorRef.current?.contains(event.target)) {
Expand All @@ -52,19 +93,35 @@ export function ActionPopover({
document.addEventListener('focusin', focusOutside);
window.addEventListener('resize', measure);
window.addEventListener('scroll', measure, true);
const observer = placement === 'auto' ? new ResizeObserver(measure) : null;
if (anchorRef.current) observer?.observe(anchorRef.current);
if (panelRef.current) observer?.observe(panelRef.current);
if (boundaryRef?.current) observer?.observe(boundaryRef.current);
if (placement === 'auto') {
window.visualViewport?.addEventListener('resize', measure);
window.visualViewport?.addEventListener('scroll', measure);
}
return () => {
document.removeEventListener('pointerdown', outside);
document.removeEventListener('focusin', focusOutside);
window.removeEventListener('resize', measure);
window.removeEventListener('scroll', measure, true);
observer?.disconnect();
if (placement === 'auto') {
window.visualViewport?.removeEventListener('resize', measure);
window.visualViewport?.removeEventListener('scroll', measure);
}
};
}, [anchorRef, onClose, inline]);
}, [anchorRef, onClose, inline, placement, boundaryRef]);

if (inline) return <>{children}</>;
return createPortal(
<div ref={panelRef} id={id} role="group" aria-label={label} tabIndex={-1}
style={position}
className="header-action-popover glass-modal fixed z-[100] overflow-y-auto rounded-xl p-1.5"
className={clsx(
'header-action-popover glass-modal fixed z-[100] overflow-y-auto rounded-xl p-1.5',
className,
)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
Expand All @@ -86,7 +143,8 @@ export function ActionPopover({
const next = event.key === 'Home' ? 0 : event.key === 'End' ? controls.length - 1
: (current + (event.key === 'ArrowDown' ? 1 : -1) + controls.length) % controls.length;
event.preventDefault();
controls[next]?.focus();
controls[next]?.focus({ preventScroll: placement === 'auto' });
if (placement === 'auto') controls[next]?.scrollIntoView({ block: 'nearest' });
}
}}>
{children}
Expand Down
7 changes: 4 additions & 3 deletions application/v2_ui/src/components/ui/dropdownPlacement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ export function dropdownPlacement(
trigger: { left: number; right: number; top: number; bottom: number },
viewport: PickerViewport,
align: 'left' | 'right',
menuSize = { width: 288, height: 320 },
) {
const margin = Math.min(12, viewport.width / 2, viewport.height / 2);
const gap = 8;
const width = Math.max(0, Math.min(288, viewport.width - margin * 2));
const width = Math.max(0, Math.min(menuSize.width, viewport.width - margin * 2));
const left = Math.max(viewport.left + margin, Math.min(
align === 'right' ? trigger.right - width : trigger.left,
viewport.left + viewport.width - margin - width,
Expand All @@ -24,8 +25,8 @@ export function dropdownPlacement(
const ceiling = viewport.top + margin;
const below = Math.max(0, floor - trigger.bottom - gap);
const above = Math.max(0, trigger.top - gap - ceiling);
const up = below < 320 && above > below;
const maxHeight = Math.min(320, up ? above : below, Math.max(0, floor - ceiling));
const up = below < menuSize.height && above > below;
const maxHeight = Math.min(menuSize.height, up ? above : below, Math.max(0, floor - ceiling));
const anchor = up
? Math.max(ceiling, Math.min(floor, trigger.top - gap))
: Math.max(ceiling, Math.min(floor, trigger.bottom + gap));
Expand Down
5 changes: 5 additions & 0 deletions application/v2_ui/src/styles/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@

.mobile-navigation-controls button,
#primary-navigation button,
.conversation-actions-popover button,
#primary-navigation a,
#workspace-section-navigation a,
.chat-header-actions button,
Expand Down Expand Up @@ -166,6 +167,10 @@
justify-content: flex-start;
}

.conversation-actions-popover svg {
flex-shrink: 0;
}

@media (max-height: 600px) {
#primary-navigation [data-rail-scroll-region] {
min-height: 0;
Expand Down
127 changes: 127 additions & 0 deletions docs/explanation/fixes/V2_CONVERSATION_MENU_LAYOUT_FIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# V2 Conversation Menu Layout Fix (0.261.406)

## Issue

The React V2 conversation menu put reference and handoff actions ahead of common
management actions. A bold, nonfunctional reference heading, indented child
actions, and mixed text sizes made the menu unnecessarily wordy and inconsistent.
Menus opened near the bottom of the conversation list could be clipped behind the
sidebar footer.

Fixed in version: **0.261.406**, recorded in
`application/single_app/config.py`.

## Root cause

`ConversationRail.tsx` rendered the panel with absolute positioning below each row,
inside the sidebar's scrolling region. That region clipped the panel, and the
glass sidebar also established a containing block. Raising z-index or using fixed
positioning inside that ancestor would not reliably free the menu.

The reference block was rendered before Rename and used a separate heading,
smaller child text, indentation, and an inconsistent copy icon size.

## Technical details

### Files modified

| File | Change |
| --- | --- |
| `application/v2_ui/src/components/chat/ConversationRail.tsx` | Flat action order, concise labels, uniform rows, destructive-action separation, accessible triggers, and guards against portalled focus or shortcuts changing the sidebar behind the menu. |
| `application/v2_ui/src/components/ui/ActionPopover.tsx` | Opt-in automatic placement for the conversation rail, reusing the existing body portal and dismissal/keyboard handling while preserving default header behavior. |
| `application/v2_ui/src/components/ui/dropdownPlacement.ts` | Optional measured menu dimensions, with existing composer dimensions unchanged by default. |
| `application/v2_ui/src/styles/theme.css` | Touch-sized menu controls outside the navigation DOM subtree and fixed-size icon alignment. |
| `application/single_app/config.py` | Application patch version updated to 0.261.406. |
| `functional_tests/test_v2_sidebar_conversation_scroll.py` | Portal/focus contracts and current desktop/mobile scroll-region expectations. |
| `functional_tests/test_v2_composer_picker_placement.py` | Measured-height placement cases and viewport-boundary checks alongside unchanged composer defaults. |
| `ui_tests/test_v2_sidebar_conversation_scroll.py` | Real-component menu order, geometry, hit-testing, keyboard, clipboard, and responsive regressions using production CSS. |
| `ui_tests/test_v2_conversation_references.py` | Updated flat-menu labels and private-source exclusion checks. |
| `ui_tests/test_v2_conversation_handoffs.py` | Updated menu label while retaining the existing handoff dialog and workflow. |
| `docs/guides/reference-and-send-conversations.md` | Updated reference actions, menu placement guidance, and clipboard troubleshooting. |
| `docs/reference/chat-controls.md` | Documented the flat reuse actions and conditional availability. |

### Menu and behavior

The menu order is Rename, Share, Pin, Hide, Export, then Send to conversation,
Use in this chat, and Copy reference. Delete has a divider and extra space above
it. Conditional People, Unpin, and Leave labels continue to reflect the existing
conversation state and permissions.

All action rows share normal-weight text, a consistent icon column, and the same
padding. The redundant reference heading is removed. Copying still uses the
existing portable reference format; an unavailable or denied clipboard now gives
visible error feedback rather than failing silently.

### Floating placement and focus

The panel renders through the shared popover's portal into `document.body`, beyond
the sidebar's clipping and glass containing block. The conversation menu opts
into placement based on its actual rendered height, trigger position, and visual
viewport. It opens below when there is room and above when the lower edge would
otherwise be clipped. Width and height remain bounded by the visible viewport.
An exceptionally small window may scroll the panel itself, not the conversation
list.

Resize and scroll updates keep the placement current; a trigger that leaves its
visible boundary dismisses the menu. Outside interaction and Escape also dismiss
it, and Escape restores trigger focus. Existing shared-popover keyboard navigation
is retained. Portalled focus does not invoke sidebar row scrolling, and shortcuts
inside the floating panel do not change the selection behind it.

Mobile controls retain their touch targets even though the panel no longer lives
under `#primary-navigation`. Existing header popovers retain their default
positioning and sizing. No new browser library or external runtime asset is used.

### Impact

Only the React V2 conversation-row menu changes. Source eligibility, Share/People,
Pin/Unpin, Delete/Leave permissions, confirmation dialogs, the reference format,
composer attachment, export, and handoff review remain unchanged. The classic
interface and bulk-action toolbar are not redesigned.

## Validation

The focused browser regressions use production AppShell, Sidebar,
ConversationRail, stores, and CSS with stubbed HTTP. They check actual element
bounds and hit targets rather than treating a larger z-index as proof of
visibility. Desktop and phone layouts run in both themes; enlarged text and short
windows verify that the menu remains reachable without scrolling the sidebar.

### Test results

| Command | Result |
| --- | --- |
| `npm --prefix .\application\v2_ui run typecheck` | Passed. |
| `npm --prefix .\application\v2_ui run build -- --outDir ..\..\ui_tests\artifacts\orchestration-plan-editor` | Passed, with Vite's large-chunk size advisory. |
| `python -m pytest .\functional_tests\test_v2_sidebar_conversation_scroll.py .\functional_tests\test_v2_composer_picker_placement.py .\functional_tests\test_v2_mobile_layout.py .\functional_tests\test_v2_conversation_multiselect.py .\functional_tests\test_v2_conversation_export.py -q --disable-warnings` | 47 passed. |
| `python -O .\functional_tests\test_v2_composer_picker_placement.py` | Passed; the Node checks also execute under optimized Python. |
| `python -m pytest .\ui_tests\test_v2_sidebar_conversation_scroll.py .\ui_tests\test_v2_conversation_references.py .\ui_tests\test_v2_conversation_handoffs.py -q --disable-warnings` | 24 passed. |
| `python -m pytest .\ui_tests\test_v2_mobile_headers.py -k "loading_status or (explicit_actions and (320 or 393))" -q --disable-warnings` | 9 passed; existing shared header popovers retain their behavior. |
| `python .\functional_tests\test_docs_app_surface_coverage.py` | 7 checks passed. |
| `python .\functional_tests\test_docs_site_quality.py` | 6 checks passed. |

The app-surface inventory was regenerated without a content change. The design
detector returned no findings for the four changed UI targets.

The browser suite writes desktop and phone menu screenshots in both themes under
`ui_tests/artifacts/sidebar-conversation-scroll/`. It verifies that the complete
menu fits near the bottom at ordinary desktop and phone sizes, each action is
hit-testable, and keyboard navigation leaves the sidebar's scroll position
unchanged. Short-window and enlarged-text cases confirm that Delete remains
reachable by scrolling only the bounded menu.

## Before and after

| Before | After |
| --- | --- |
| Reference heading and indented actions precede Rename. | Management actions come first; concise reuse actions follow Export. |
| Text weights, sizes, and icon alignment vary. | Every action has the same normal-weight text and alignment. |
| Delete immediately follows Export. | Delete/Leave has clear destructive-action separation. |
| Low menus can disappear behind the footer. | Portalled menus flip above the trigger and stay viewport-bounded. |
| Keyboard focus in an overlay can be mistaken for sidebar-row focus. | Sidebar scrolling and selection shortcuts ignore portalled controls. |

## Related documentation

- [Reference and send between conversations](../../guides/reference-and-send-conversations.md)
- [Chat interface controls](../../reference/chat-controls.md)
- [V2 sidebar conversation scrolling](V2_SIDEBAR_CONVERSATION_SCROLL_FIX.md)
Loading
Loading