Skip to content

Commit efe6e97

Browse files
authored
Simplify autopilot session page layout (#5889)
* Simplify autopilot session page layout - Move session ID from page title to breadcrumbs - Add plus button for new session in sidebar nav - Reduce margin between breadcrumbs and chat area - Shorten breadcrumb label from "Autopilot Sessions" to "Autopilot" * Fix nested link issue and move all titles to breadcrumbs - Use button with navigate() instead of nested Link - Move "New Session" from page title to breadcrumbs - Remove page title entirely for all states * Improve plus button hover state and add cursor pointer * Simplify autopilot session page layout - Remove border around chat messages, use full-page scroll - Add fixed header with breadcrumbs and fade gradient overlay - Add fixed footer with chat input - Dynamic footer height measurement for responsive spacer - Use bg-bg-secondary for consistent background color * Fix autopilot sidebar plus button placement Move plus button from navigation loop to autopilot item where it actually renders. The previous code checked for section.title === "Autopilot" but Autopilot is rendered separately, not in the navigation array. * Replace spinner with animated ellipsis in status indicator - Create reusable AnimatedEllipsis component - Remove Loader2 spinner from status indicator - Use animated dots for thinking/loading states * Add configurable AnimatedEllipsis component - Add reserveWidth option (default: true) to prevent layout shift - Add absolute option for positioning without affecting layout - Export ReservedWidth utility for other use cases - Use absolute positioning in status indicator for centered text * Refactor AnimatedEllipsis to use EllipsisMode enum - Replace boolean props with EllipsisMode enum (Dynamic, FixedWidth, Absolute) - Add horizontal margin to status indicator - Export EllipsisMode for explicit mode selection * Move tool approval card to fixed footer and improve status indicator - Move PendingToolCallCard from scrollable content to fixed footer - Lift authorization state and handlers to parent component - Add shared Divider component for consistent styling - Use AnimatedEllipsis in status indicator instead of static "..." - Fix loading skeleton vertical centering with min-h-[50vh] - Add className prop to PendingToolCallCard for pointer-events * Extract FadeGradient component with direction and color enums - Create reusable FadeGradient component in ~/components/ui/ - Add FadeDirection enum (Top, Bottom) for gradient direction - Add SurfaceColor enum (Primary, Secondary, Tertiary) for common surfaces - Fix scroll position adjustment when footer height changes - Separate footer height measurement from scroll adjustment effect * Reduce fade gradient height from h-16 to h-10 * Add useElementHeight hook and improve session page layout - Extract useElementHeight hook for reusable ResizeObserver logic - Dynamic header/footer height measurement via hook - Use padding instead of spacer divs for header/footer spacing - Consolidate session state reset into single useEffect - Extract handleScroll to named function for clarity - Skip scroll adjustment on initial mount to prevent jump * Only adjust scroll when user is near bottom of content * Fix scroll adjustment for large footer height changes * Fix nested interactive elements in sidebar - use absolute positioning for + button * Change session divider text from 'Started' to 'Start' * Fix prettier formatting * Move SSE error message from scrollable content to fixed header * Extract ErrorBanner component * Fix scroll adjustment: only adjust on footer growth, reset ref on session change * Fix cooldown ref reset on session change and bottom fade init * Use lodash-es debounce instead of local implementation * Simplify isEventsLoading logic (align with #5948) * Clear hasLoadError when events load (align with #5953)
1 parent 942f4ab commit efe6e97

8 files changed

Lines changed: 533 additions & 301 deletions

File tree

ui/app/components/autopilot/ChatInput.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,9 @@ export function ChatInput({
175175
onChange={(e) => setText(e.target.value)}
176176
onKeyDown={handleKeyDown}
177177
placeholder={placeholder}
178-
disabled={disabled}
178+
disabled={disabled || isSubmitting}
179179
className={cn(
180-
"resize-none overflow-y-auto",
180+
"bg-bg-secondary resize-none overflow-y-auto",
181181
"rounded-md py-[11px] pr-14 pl-4 text-sm",
182182
"focus-visible:border-fg-muted focus-visible:ring-0",
183183
)}

ui/app/components/autopilot/EventStream.tsx

Lines changed: 32 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import {
2-
AlertCircle,
3-
AlertTriangle,
4-
ChevronRight,
5-
Loader2,
6-
} from "lucide-react";
1+
import { AlertCircle, AlertTriangle, ChevronRight } from "lucide-react";
72
import { Component, type RefObject, useState } from "react";
3+
import {
4+
AnimatedEllipsis,
5+
EllipsisMode,
6+
} from "~/components/ui/AnimatedEllipsis";
87
import { Markdown, ReadOnlyCodeBlock } from "~/components/ui/markdown";
98
import { Skeleton } from "~/components/ui/skeleton";
109
import { logger } from "~/utils/logger";
@@ -483,16 +482,20 @@ function EventSkeletons({ count = 3 }: { count?: number }) {
483482
);
484483
}
485484

486-
function SessionStartedDivider() {
485+
function Divider({ children }: { children: React.ReactNode }) {
487486
return (
488-
<div className="flex items-center gap-4 py-2">
487+
<div className="flex items-center gap-5 py-2">
489488
<div className="border-border flex-1 border-t" />
490-
<span className="text-fg-muted text-xs">Started</span>
489+
<span className="text-fg-muted relative text-xs">{children}</span>
491490
<div className="border-border flex-1 border-t" />
492491
</div>
493492
);
494493
}
495494

495+
function SessionStartDivider() {
496+
return <Divider>Start</Divider>;
497+
}
498+
496499
function OptimisticMessageItem({ message }: { message: OptimisticMessage }) {
497500
// Optimistic messages are shown after POST succeeds, so the message is saved.
498501
// We display it like a regular user message, with a skeleton for the timestamp.
@@ -509,42 +512,36 @@ function OptimisticMessageItem({ message }: { message: OptimisticMessage }) {
509512
);
510513
}
511514

512-
function getStatusLabel(status: AutopilotStatus): string {
515+
function getStatusLabel(status: AutopilotStatus): {
516+
text: string;
517+
showEllipsis: boolean;
518+
} {
513519
switch (status.status) {
514520
case "idle":
515-
return "Ready";
521+
return { text: "Ready", showEllipsis: false };
516522
case "server_side_processing":
517-
return "Thinking...";
523+
return { text: "Thinking", showEllipsis: true };
518524
case "waiting_for_tool_call_authorization":
519-
return "Waiting";
525+
return { text: "Waiting", showEllipsis: false };
520526
case "waiting_for_tool_execution":
521-
return "Executing tool...";
527+
return { text: "Executing tool", showEllipsis: true };
522528
case "waiting_for_retry":
523-
return "Something went wrong. Retrying...";
529+
return { text: "Something went wrong. Retrying", showEllipsis: true };
524530
case "failed":
525-
return "Something went wrong. Please try again.";
531+
return {
532+
text: "Something went wrong. Please try again.",
533+
showEllipsis: false,
534+
};
526535
}
527536
}
528537

529-
function isLoadingStatus(status: AutopilotStatus): boolean {
530-
return (
531-
status.status === "server_side_processing" ||
532-
status.status === "waiting_for_tool_execution" ||
533-
status.status === "waiting_for_retry"
534-
);
535-
}
536-
537538
function StatusIndicator({ status }: { status: AutopilotStatus }) {
538-
const showSpinner = isLoadingStatus(status);
539+
const { text, showEllipsis } = getStatusLabel(status);
539540
return (
540-
<div className="flex items-center gap-4 py-2">
541-
<div className="border-border flex-1 border-t" />
542-
<span className="text-fg-muted flex items-center gap-1.5 text-xs">
543-
{getStatusLabel(status)}
544-
{showSpinner && <Loader2 className="h-3 w-3 animate-spin" />}
545-
</span>
546-
<div className="border-border flex-1 border-t" />
547-
</div>
541+
<Divider>
542+
{text}
543+
{showEllipsis && <AnimatedEllipsis mode={EllipsisMode.Absolute} />}
544+
</Divider>
548545
);
549546
}
550547

@@ -560,10 +557,10 @@ export default function EventStream({
560557
}: EventStreamProps) {
561558
return (
562559
<div className={cn("flex flex-col gap-3", className)}>
563-
{/* Session started indicator, or sentinel for loading more */}
560+
{/* Session start indicator, or sentinel for loading more */}
564561
{/* Show divider when we've reached the start OR when there are optimistic messages (new session) */}
565562
{(hasReachedStart || optimisticMessages.length > 0) && !isLoadingOlder ? (
566-
<SessionStartedDivider />
563+
<SessionStartDivider />
567564
) : (
568565
<div ref={topSentinelRef} className="h-1" aria-hidden="true" />
569566
)}

ui/app/components/autopilot/PendingToolCallCard.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type PendingToolCallCardProps = {
1212
onAuthorize: (approved: boolean) => void;
1313
additionalCount: number;
1414
isInCooldown?: boolean;
15+
className?: string;
1516
};
1617

1718
export function PendingToolCallCard({
@@ -21,6 +22,7 @@ export function PendingToolCallCard({
2122
onAuthorize,
2223
additionalCount,
2324
isInCooldown = false,
25+
className,
2426
}: PendingToolCallCardProps) {
2527
const [isExpanded, setIsExpanded] = useState(false);
2628
const [confirmReject, setConfirmReject] = useState(false);
@@ -60,6 +62,7 @@ export function PendingToolCallCard({
6062
"flex flex-col gap-2 rounded-md border border-blue-300 bg-blue-50 px-4 py-3 dark:border-blue-700 dark:bg-blue-950/30",
6163
isInCooldown &&
6264
"animate-in fade-in zoom-in-95 duration-1000 ease-in-out",
65+
className,
6366
)}
6467
>
6568
<div className="flex items-center justify-between gap-4">

ui/app/components/layout/app.sidebar.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
SidebarCollapse,
1414
SidebarExpand,
1515
} from "~/components/icons/Icons";
16-
import { KeyRound, LayoutGrid } from "lucide-react";
16+
import { KeyRound, LayoutGrid, Plus } from "lucide-react";
1717
import {
1818
Sidebar,
1919
SidebarContent,
@@ -162,7 +162,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
162162
</SidebarMenuButton>
163163
</SidebarMenuItem>
164164
{autopilotAvailable && (
165-
<SidebarMenuItem className="list-none">
165+
<SidebarMenuItem className="relative list-none">
166166
<SidebarMenuButton
167167
asChild
168168
tooltip={state === "collapsed" ? "Autopilot" : undefined}
@@ -175,6 +175,15 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
175175
</span>
176176
</Link>
177177
</SidebarMenuButton>
178+
{state === "expanded" && (
179+
<Link
180+
to="/autopilot/sessions/new"
181+
className="text-fg-muted hover:text-fg-primary absolute top-1/2 right-2 z-10 -translate-y-1/2 rounded p-0.5 transition-colors"
182+
aria-label="New session"
183+
>
184+
<Plus className="h-4 w-4" />
185+
</Link>
186+
)}
178187
</SidebarMenuItem>
179188
)}
180189
</SidebarGroupContent>
@@ -190,11 +199,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
190199
tooltip={state === "collapsed" ? item.title : undefined}
191200
isActive={activePathUtils.isActive(item.url)}
192201
>
193-
<Link
194-
to={item.url}
195-
className="flex items-center gap-2"
196-
onClick={(e) => e.stopPropagation()}
197-
>
202+
<Link to={item.url} className="flex items-center gap-2">
198203
<item.icon className="h-4 w-4" />
199204
<span className="whitespace-nowrap transition-opacity duration-200 group-data-[collapsible=icon]:opacity-0">
200205
{item.title}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useState, useEffect } from "react";
2+
import { cn } from "~/utils/common";
3+
4+
type ReservedWidthProps = {
5+
/** The text whose width to reserve (rendered invisibly) */
6+
children: string;
7+
};
8+
9+
/** Reserves the exact width of the given text without rendering it visibly */
10+
export function ReservedWidth({ children }: ReservedWidthProps) {
11+
return <span className="invisible">{children}</span>;
12+
}
13+
14+
/** Layout mode for AnimatedEllipsis */
15+
export const EllipsisMode = {
16+
/** Dynamic width - shifts layout as dots change */
17+
Dynamic: "dynamic",
18+
/** Fixed width - reserves space for "..." to prevent layout shift */
19+
FixedWidth: "fixed-width",
20+
/** Absolute - positioned outside layout flow */
21+
Absolute: "absolute",
22+
} as const;
23+
24+
export type EllipsisMode = (typeof EllipsisMode)[keyof typeof EllipsisMode];
25+
26+
type AnimatedEllipsisProps = {
27+
/** Animation interval in ms */
28+
interval?: number;
29+
/** Layout mode */
30+
mode?: EllipsisMode;
31+
className?: string;
32+
};
33+
34+
export function AnimatedEllipsis({
35+
interval = 400,
36+
mode = EllipsisMode.FixedWidth,
37+
className,
38+
}: AnimatedEllipsisProps) {
39+
const [dots, setDots] = useState(0);
40+
41+
useEffect(() => {
42+
const timer = setInterval(() => {
43+
setDots((prev) => (prev + 1) % 4);
44+
}, interval);
45+
return () => clearInterval(timer);
46+
}, [interval]);
47+
48+
const dotsContent = ".".repeat(dots);
49+
50+
if (mode === EllipsisMode.Absolute) {
51+
return (
52+
<span className={cn("absolute left-full", className)}>{dotsContent}</span>
53+
);
54+
}
55+
56+
if (mode === EllipsisMode.FixedWidth) {
57+
return (
58+
<span className={cn("relative inline-block", className)}>
59+
<ReservedWidth>...</ReservedWidth>
60+
<span className="absolute left-0">{dotsContent}</span>
61+
</span>
62+
);
63+
}
64+
65+
return <span className={className}>{dotsContent}</span>;
66+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { cn } from "~/utils/common";
2+
3+
/** Direction the gradient fades toward */
4+
export const FadeDirection = {
5+
/** Fades from solid at top to transparent at bottom */
6+
Top: "top",
7+
/** Fades from solid at bottom to transparent at top */
8+
Bottom: "bottom",
9+
} as const;
10+
11+
export type FadeDirection = (typeof FadeDirection)[keyof typeof FadeDirection];
12+
13+
/** Common surface colors for backgrounds and gradients */
14+
export const SurfaceColor = {
15+
Primary: "primary",
16+
Secondary: "secondary",
17+
Tertiary: "tertiary",
18+
} as const;
19+
20+
export type SurfaceColor = (typeof SurfaceColor)[keyof typeof SurfaceColor];
21+
22+
const surfaceColorClasses: Record<SurfaceColor, string> = {
23+
[SurfaceColor.Primary]: "from-bg-primary",
24+
[SurfaceColor.Secondary]: "from-bg-secondary",
25+
[SurfaceColor.Tertiary]: "from-bg-tertiary",
26+
};
27+
28+
type FadeGradientProps = {
29+
direction: FadeDirection;
30+
visible: boolean;
31+
color?: SurfaceColor;
32+
className?: string;
33+
};
34+
35+
export function FadeGradient({
36+
direction,
37+
visible,
38+
color = SurfaceColor.Secondary,
39+
className,
40+
}: FadeGradientProps) {
41+
return (
42+
<div
43+
className={cn(
44+
"h-10",
45+
surfaceColorClasses[color],
46+
"to-transparent",
47+
direction === FadeDirection.Top
48+
? "bg-gradient-to-b"
49+
: "bg-gradient-to-t",
50+
"transition-opacity duration-75",
51+
visible ? "opacity-100" : "opacity-0",
52+
className,
53+
)}
54+
/>
55+
);
56+
}

ui/app/hooks/useElementHeight.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useEffect, useRef, useState } from "react";
2+
3+
/**
4+
* Hook to measure an element's height dynamically using ResizeObserver.
5+
* Returns a ref to attach to the element and the current height.
6+
*/
7+
export function useElementHeight(initialHeight: number = 0) {
8+
const ref = useRef<HTMLDivElement | null>(null);
9+
const [height, setHeight] = useState(initialHeight);
10+
11+
useEffect(() => {
12+
const element = ref.current;
13+
if (!element) return;
14+
15+
const measureHeight = () => {
16+
const newHeight = element.offsetHeight;
17+
if (newHeight > 0) {
18+
setHeight((prev) => (prev !== newHeight ? newHeight : prev));
19+
}
20+
};
21+
22+
measureHeight();
23+
24+
const resizeObserver = new ResizeObserver(measureHeight);
25+
resizeObserver.observe(element);
26+
27+
return () => resizeObserver.disconnect();
28+
}, []);
29+
30+
return [ref, height] as const;
31+
}

0 commit comments

Comments
 (0)