Skip to content

Commit 21810b8

Browse files
cursoragentmsukkari
andcommitted
fix: improve Ask view layout adjustment for narrow/split windows
- Add container-width detection using ResizeObserver to detect narrow containers - Automatically switch to vertical layout when container width < 768px - Add toggle button to collapse/expand code references panel - Support both horizontal (wide) and vertical (narrow) layouts - In vertical layout, code references appear below the answer with a collapsible section - In horizontal layout, add ability to collapse the right panel for full-width answer view Fixes issue where code citations become impossible to see in split window mode Co-authored-by: Michael Sukkarieh <msukkari@users.noreply.github.com>
1 parent 251f5b5 commit 21810b8

File tree

1 file changed

+214
-91
lines changed

1 file changed

+214
-91
lines changed

packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx

Lines changed: 214 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import { AnimatedResizableHandle } from '@/components/ui/animatedResizableHandle';
44
import { ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
55
import { Skeleton } from '@/components/ui/skeleton';
6-
import { CheckCircle, Loader2 } from 'lucide-react';
6+
import { Button } from '@/components/ui/button';
7+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
8+
import { CheckCircle, Loader2, PanelLeftClose, PanelLeft } from 'lucide-react';
79
import { CSSProperties, forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
810
import scrollIntoView from 'scroll-into-view-if-needed';
911
import { Reference, referenceSchema, SBChatMessage, Source } from "../../types";
@@ -16,6 +18,9 @@ import { ReferencedSourcesListView } from './referencedSourcesListView';
1618
import isEqual from "fast-deep-equal/react";
1719
import { ANSWER_TAG } from '../../constants';
1820

21+
// Minimum width in pixels for the horizontal layout to work well
22+
const MIN_HORIZONTAL_LAYOUT_WIDTH = 768;
23+
1924
interface ChatThreadListItemProps {
2025
userMessage: SBChatMessage;
2126
assistantMessage?: SBChatMessage;
@@ -33,6 +38,7 @@ const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListIte
3338
chatId,
3439
index,
3540
}, ref) => {
41+
const containerRef = useRef<HTMLDivElement>(null);
3642
const leftPanelRef = useRef<HTMLDivElement>(null);
3743
const [leftPanelHeight, setLeftPanelHeight] = useState<number | null>(null);
3844
const answerRef = useRef<HTMLDivElement>(null);
@@ -43,6 +49,32 @@ const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListIte
4349
const hasAutoCollapsed = useRef(false);
4450
const userHasManuallyExpanded = useRef(false);
4551

52+
// Layout state: track container width and user preference
53+
const [containerWidth, setContainerWidth] = useState<number | null>(null);
54+
const [isReferencePanelCollapsed, setIsReferencePanelCollapsed] = useState(false);
55+
56+
// Determine if we should use vertical layout based on container width
57+
const shouldUseVerticalLayout = containerWidth !== null && containerWidth < MIN_HORIZONTAL_LAYOUT_WIDTH;
58+
59+
// Monitor container width for responsive layout
60+
useEffect(() => {
61+
if (!containerRef.current) {
62+
return;
63+
}
64+
65+
const resizeObserver = new ResizeObserver((entries) => {
66+
for (const entry of entries) {
67+
setContainerWidth(entry.contentRect.width);
68+
}
69+
});
70+
71+
resizeObserver.observe(containerRef.current);
72+
73+
return () => {
74+
resizeObserver.disconnect();
75+
};
76+
}, []);
77+
4678
const userQuestion = useMemo(() => {
4779
return userMessage.parts.length > 0 && userMessage.parts[0].type === 'text' ? userMessage.parts[0].text : '';
4880
}, [userMessage]);
@@ -305,10 +337,162 @@ const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListIte
305337
}, [references, sources]);
306338

307339

340+
// Content for the answer/question panel
341+
const answerPanelContent = (
342+
<div
343+
ref={leftPanelRef}
344+
className="py-4 h-full"
345+
>
346+
<div className="flex flex-row gap-2 mb-4">
347+
{isStreaming ? (
348+
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0 mt-1.5" />
349+
) : (
350+
<CheckCircle className="w-4 h-4 text-green-700 flex-shrink-0 mt-1.5" />
351+
)}
352+
<MarkdownRenderer
353+
content={userQuestion.trim()}
354+
className="prose-p:m-0"
355+
escapeHtml={true}
356+
/>
357+
</div>
358+
359+
{isThinking && (
360+
<div className="space-y-4 mb-4">
361+
<Skeleton className="h-4 max-w-32" />
362+
<div className="space-y-2">
363+
<Skeleton className="h-3 max-w-72" />
364+
<Skeleton className="h-3 max-w-64" />
365+
<Skeleton className="h-3 max-w-56" />
366+
</div>
367+
</div>
368+
)}
369+
370+
<DetailsCard
371+
chatId={chatId}
372+
isExpanded={isDetailsPanelExpanded}
373+
onExpandedChanged={onExpandDetailsPanel}
374+
isThinking={isThinking}
375+
isStreaming={isStreaming}
376+
thinkingSteps={uiVisibleThinkingSteps}
377+
metadata={assistantMessage?.metadata}
378+
/>
379+
380+
{(answerPart && assistantMessage) ? (
381+
<AnswerCard
382+
ref={answerRef}
383+
answerText={answerPart.text}
384+
chatId={chatId}
385+
messageId={assistantMessage.id}
386+
traceId={assistantMessage.metadata?.traceId}
387+
/>
388+
) : !isStreaming && (
389+
<p className="text-destructive">Error: No answer response was provided</p>
390+
)}
391+
</div>
392+
);
393+
394+
// Content for the references panel
395+
const referencesPanelContent = (
396+
<>
397+
{referencedFileSources.length > 0 ? (
398+
<ReferencedSourcesListView
399+
index={index}
400+
references={references}
401+
sources={referencedFileSources}
402+
hoveredReference={hoveredReference}
403+
selectedReference={selectedReference}
404+
onSelectedReferenceChanged={setSelectedReference}
405+
onHoveredReferenceChanged={setHoveredReference}
406+
style={shouldUseVerticalLayout || isReferencePanelCollapsed ? {} : rightPanelStyle}
407+
/>
408+
) : isStreaming ? (
409+
<div className="space-y-4">
410+
{Array.from({ length: 3 }).map((_, index) => (
411+
<Skeleton key={index} className="w-full h-48" />
412+
))}
413+
</div>
414+
) : (
415+
<div className="p-4 text-center text-muted-foreground text-sm">
416+
No file references found
417+
</div>
418+
)}
419+
</>
420+
);
421+
422+
// Layout toggle button for narrow containers
423+
const layoutToggleButton = referencedFileSources.length > 0 && (
424+
<Tooltip>
425+
<TooltipTrigger asChild>
426+
<Button
427+
variant="ghost"
428+
size="sm"
429+
className="h-7 w-7 p-0"
430+
onClick={() => setIsReferencePanelCollapsed(!isReferencePanelCollapsed)}
431+
>
432+
{isReferencePanelCollapsed ? (
433+
<PanelLeft className="h-4 w-4" />
434+
) : (
435+
<PanelLeftClose className="h-4 w-4" />
436+
)}
437+
</Button>
438+
</TooltipTrigger>
439+
<TooltipContent side="bottom">
440+
{isReferencePanelCollapsed ? 'Show code references' : 'Hide code references'}
441+
</TooltipContent>
442+
</Tooltip>
443+
);
444+
445+
// Vertical layout for narrow containers
446+
if (shouldUseVerticalLayout) {
447+
return (
448+
<div
449+
className="flex flex-col relative"
450+
ref={(node) => {
451+
// Handle both refs
452+
containerRef.current = node;
453+
if (typeof ref === 'function') {
454+
ref(node);
455+
} else if (ref) {
456+
ref.current = node;
457+
}
458+
}}
459+
>
460+
{/* Answer panel with toggle button */}
461+
<div className="relative">
462+
{referencedFileSources.length > 0 && (
463+
<div className="absolute top-4 right-0 z-10">
464+
{layoutToggleButton}
465+
</div>
466+
)}
467+
{answerPanelContent}
468+
</div>
469+
470+
{/* References panel - collapsible in vertical layout */}
471+
{!isReferencePanelCollapsed && referencedFileSources.length > 0 && (
472+
<div className="border-t pt-4 mt-4">
473+
<div className="text-sm font-medium text-muted-foreground mb-3">Code References</div>
474+
<div className="max-h-[50vh] overflow-y-auto">
475+
{referencesPanelContent}
476+
</div>
477+
</div>
478+
)}
479+
</div>
480+
);
481+
}
482+
483+
// Horizontal layout for wider containers
308484
return (
309485
<div
310-
className="flex flex-col md:flex-row relative min-h-[calc(100vh-250px)]"
311-
ref={ref}
486+
className="flex flex-col relative min-h-[calc(100vh-250px)]"
487+
ref={(node) => {
488+
// Handle both refs
489+
containerRef.current = node;
490+
if (typeof ref === 'function') {
491+
ref(node);
492+
} else if (ref) {
493+
ref.current = node;
494+
}
495+
}}
312496
>
313497
<ResizablePanelGroup
314498
direction="horizontal"
@@ -319,103 +503,42 @@ const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListIte
319503
>
320504
<ResizablePanel
321505
order={1}
322-
minSize={30}
323-
maxSize={70}
324-
defaultSize={50}
506+
minSize={isReferencePanelCollapsed ? 100 : 30}
507+
maxSize={isReferencePanelCollapsed ? 100 : 70}
508+
defaultSize={isReferencePanelCollapsed ? 100 : 50}
325509
style={{
326510
overflow: 'visible',
327511
}}
328512
>
329-
<div
330-
ref={leftPanelRef}
331-
className="py-4 h-full"
332-
>
333-
<div className="flex flex-row gap-2 mb-4">
334-
{isStreaming ? (
335-
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0 mt-1.5" />
336-
) : (
337-
<CheckCircle className="w-4 h-4 text-green-700 flex-shrink-0 mt-1.5" />
338-
)}
339-
<MarkdownRenderer
340-
content={userQuestion.trim()}
341-
className="prose-p:m-0"
342-
escapeHtml={true}
343-
/>
344-
</div>
345-
346-
{isThinking && (
347-
<div className="space-y-4 mb-4">
348-
<Skeleton className="h-4 max-w-32" />
349-
<div className="space-y-2">
350-
<Skeleton className="h-3 max-w-72" />
351-
<Skeleton className="h-3 max-w-64" />
352-
<Skeleton className="h-3 max-w-56" />
353-
</div>
513+
<div className="relative">
514+
{referencedFileSources.length > 0 && (
515+
<div className="absolute top-4 right-0 z-10">
516+
{layoutToggleButton}
354517
</div>
355518
)}
356-
357-
<DetailsCard
358-
chatId={chatId}
359-
isExpanded={isDetailsPanelExpanded}
360-
onExpandedChanged={onExpandDetailsPanel}
361-
isThinking={isThinking}
362-
isStreaming={isStreaming}
363-
thinkingSteps={uiVisibleThinkingSteps}
364-
metadata={assistantMessage?.metadata}
365-
/>
366-
367-
{(answerPart && assistantMessage) ? (
368-
<AnswerCard
369-
ref={answerRef}
370-
answerText={answerPart.text}
371-
chatId={chatId}
372-
messageId={assistantMessage.id}
373-
traceId={assistantMessage.metadata?.traceId}
374-
/>
375-
) : !isStreaming && (
376-
<p className="text-destructive">Error: No answer response was provided</p>
377-
)}
519+
{answerPanelContent}
378520
</div>
379521
</ResizablePanel>
380-
<AnimatedResizableHandle className='mx-4' />
381-
<ResizablePanel
382-
order={2}
383-
minSize={30}
384-
maxSize={70}
385-
defaultSize={50}
386-
style={{
387-
overflow: 'clip',
388-
maxHeight: '100%',
389-
minWidth: 0,
390-
}}
391-
>
392-
<div
393-
className="sticky top-0"
394-
>
395-
{referencedFileSources.length > 0 ? (
396-
<ReferencedSourcesListView
397-
index={index}
398-
references={references}
399-
sources={referencedFileSources}
400-
hoveredReference={hoveredReference}
401-
selectedReference={selectedReference}
402-
onSelectedReferenceChanged={setSelectedReference}
403-
onHoveredReferenceChanged={setHoveredReference}
404-
style={rightPanelStyle}
405-
/>
406-
) : isStreaming ? (
407-
<div className="space-y-4">
408-
{Array.from({ length: 3 }).map((_, index) => (
409-
<Skeleton key={index} className="w-full h-48" />
410-
))}
411-
</div>
412-
) : (
413-
<div className="p-4 text-center text-muted-foreground text-sm">
414-
No file references found
522+
{!isReferencePanelCollapsed && (
523+
<>
524+
<AnimatedResizableHandle className='mx-4' />
525+
<ResizablePanel
526+
order={2}
527+
minSize={30}
528+
maxSize={70}
529+
defaultSize={50}
530+
style={{
531+
overflow: 'clip',
532+
maxHeight: '100%',
533+
minWidth: 0,
534+
}}
535+
>
536+
<div className="sticky top-0">
537+
{referencesPanelContent}
415538
</div>
416-
)}
417-
</div>
418-
</ResizablePanel>
539+
</ResizablePanel>
540+
</>
541+
)}
419542
</ResizablePanelGroup>
420543
</div>
421544
)

0 commit comments

Comments
 (0)