Skip to content

Commit 32faa39

Browse files
Preserve scroll position when switching between chat and file tabs (#1125)
## Summary Fixes #1070 — the artifact viewer now preserves scroll position when switching between chat and file/task tabs. The root cause was that `renderMainContent()` conditionally mounted/unmounted the Chat, FileViewer, and TaskTranscriptViewer components on every tab switch. Each mount reset scroll position and React Query cache. The fix renders all tab content simultaneously and toggles visibility with the CSS `hidden` class (`display: none`). Inactive FileViewers also stop polling via a new `isActive` prop so background tabs don't make unnecessary fetches. ## What changed - **page.tsx** — `renderMainContent()` now renders chat, all open file tabs, and all open task tabs at once; inactive ones get `className="hidden"` - **file-viewer.tsx** — added `isActive` prop (default `true`) that gates `refetchInterval` so hidden tabs don't poll - **file-viewer.test.tsx** — 3 new tests covering the polling logic based on `isActive` and `sessionPhase` ## Test plan - [x] Unit tests pass (11/11 in file-viewer, full suite green) - [x] TypeScript compiles clean (`tsc --noEmit`) - [x] ESLint clean (only pre-existing warning) - [x] Manual test: open a file tab, scroll down, switch to chat, switch back — scroll position is preserved _Video recording of the manual test will be attached in a comment below._ ![ambient-scroll](https://github.com/user-attachments/assets/3db85cfb-ea28-47a2-af6e-cb675338729a) --------- Signed-off-by: Vishali <vsanghis@redhat.com>
1 parent 30666a6 commit 32faa39

4 files changed

Lines changed: 171 additions & 103 deletions

File tree

components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/file-viewer.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,47 @@ describe('FileViewer', () => {
118118
expect(downloadButtons[0].hasAttribute('disabled')).toBe(false);
119119
});
120120

121+
describe('isActive prop controls polling', () => {
122+
it('polls when isActive and session is Running', () => {
123+
mockUseWorkspaceFile.mockReturnValue({
124+
data: 'hello',
125+
isLoading: false,
126+
error: null,
127+
} as ReturnType<typeof useWorkspaceFile>);
128+
129+
render(<FileViewer {...defaultProps} sessionPhase="Running" isActive={true} />);
130+
131+
const opts = mockUseWorkspaceFile.mock.calls.at(-1)?.[3];
132+
expect(opts?.refetchInterval).toBe(5000);
133+
});
134+
135+
it('does not poll when isActive is false even if session is Running', () => {
136+
mockUseWorkspaceFile.mockReturnValue({
137+
data: 'hello',
138+
isLoading: false,
139+
error: null,
140+
} as ReturnType<typeof useWorkspaceFile>);
141+
142+
render(<FileViewer {...defaultProps} sessionPhase="Running" isActive={false} />);
143+
144+
const opts = mockUseWorkspaceFile.mock.calls.at(-1)?.[3];
145+
expect(opts?.refetchInterval).toBe(false);
146+
});
147+
148+
it('does not poll when session is not Running regardless of isActive', () => {
149+
mockUseWorkspaceFile.mockReturnValue({
150+
data: 'hello',
151+
isLoading: false,
152+
error: null,
153+
} as ReturnType<typeof useWorkspaceFile>);
154+
155+
render(<FileViewer {...defaultProps} sessionPhase="Completed" isActive={true} />);
156+
157+
const opts = mockUseWorkspaceFile.mock.calls.at(-1)?.[3];
158+
expect(opts?.refetchInterval).toBe(false);
159+
});
160+
});
161+
121162
describe('download uses direct link instead of triggerDownload', () => {
122163
it('downloads via direct workspace API link, not triggerDownload', () => {
123164
mockUseWorkspaceFile.mockReturnValue({

components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/file-viewer.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ type FileViewerProps = {
1212
sessionName: string;
1313
filePath: string;
1414
sessionPhase?: string;
15+
/** whether this tab is the currently visible one (gates polling to avoid background fetches) */
16+
isActive?: boolean;
1517
};
1618

1719
/** Displays a workspace file with download and refresh controls. */
@@ -20,18 +22,17 @@ export function FileViewer({
2022
sessionName,
2123
filePath,
2224
sessionPhase,
25+
isActive = true,
2326
}: FileViewerProps) {
2427
const {
2528
data: content,
2629
isLoading,
2730
error,
2831
refetch,
2932
} = useWorkspaceFile(projectName, sessionName, filePath, {
30-
// Refetch when tab is first opened
3133
refetchOnMount: true,
32-
// Only poll while actively viewing this file tab (component is mounted) AND session is running
33-
// Automatically stops when switching to another tab (component unmounts)
34-
refetchInterval: sessionPhase === "Running" ? 5000 : false,
34+
// only poll when the tab is visible and session is running
35+
refetchInterval: isActive && sessionPhase === "Running" ? 5000 : false,
3536
});
3637

3738
const fileName = filePath.split("/").pop() ?? "file";

components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/task-transcript-viewer.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,23 @@ type TaskTranscriptViewerProps = {
1515
sessionName: string
1616
taskId: string
1717
task?: BackgroundTask
18+
/** whether this tab is the currently visible one (gates polling to avoid background fetches) */
19+
isActive?: boolean
1820
}
1921

2022
export function TaskTranscriptViewer({
2123
projectName,
2224
sessionName,
2325
taskId,
2426
task,
27+
isActive = true,
2528
}: TaskTranscriptViewerProps) {
2629
const isRunning = task?.status === "running"
2730

2831
const { data, isLoading, error, refetch, isFetching } = useQuery({
2932
queryKey: ["task-output", projectName, sessionName, taskId],
3033
queryFn: () => getTaskOutput(projectName, sessionName, taskId),
31-
refetchInterval: isRunning ? 5000 : false,
34+
refetchInterval: isActive && isRunning ? 5000 : false,
3235
})
3336

3437
const messages = useMemo(

components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx

Lines changed: 121 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1578,113 +1578,136 @@ export default function ProjectSessionDetailPage({
15781578
);
15791579
}
15801580

1581-
// Chat/FileViewer/TaskTranscript content rendering helper
1581+
// all tab content is rendered simultaneously and toggled via CSS `hidden`
1582+
// so scroll position, input state, and react query cache are preserved across tab switches
15821583
const renderMainContent = () => {
1583-
if (fileTabs.activeTab.type === "task") {
1584-
const task = aguiState.backgroundTasks.get(fileTabs.activeTab.taskId);
1585-
return (
1586-
<TaskTranscriptViewer
1587-
projectName={projectName}
1588-
sessionName={sessionName}
1589-
taskId={fileTabs.activeTab.taskId}
1590-
task={task}
1591-
/>
1592-
);
1593-
}
1584+
const isChatActive = fileTabs.activeTab.type === "chat";
15941585

1595-
if (fileTabs.activeTab.type === "file") {
1596-
return (
1597-
<FileViewer
1598-
projectName={projectName}
1599-
sessionName={sessionName}
1600-
filePath={fileTabs.activeTab.path}
1601-
sessionPhase={phase}
1602-
/>
1603-
);
1604-
}
1605-
1606-
// Chat view
16071586
return (
1608-
<Card className="relative flex-1 flex flex-col overflow-hidden py-0 border-0 rounded-none">
1609-
<CardContent className="px-6 pt-0 pb-0 flex-1 flex flex-col overflow-hidden">
1610-
{repoChanging && (
1611-
<div className="absolute inset-0 bg-background/90 backdrop-blur-sm z-10 flex items-center justify-center rounded-lg">
1612-
<Alert className="max-w-md mx-4">
1613-
<Loader2 className="h-4 w-4 animate-spin" />
1614-
<AlertTitle>Updating Repositories...</AlertTitle>
1615-
<AlertDescription>
1616-
<p>Please wait while repositories are being updated. This may take 10-20 seconds...</p>
1617-
</AlertDescription>
1618-
</Alert>
1619-
</div>
1620-
)}
1621-
1622-
<div className="relative flex flex-col flex-1 overflow-hidden">
1623-
{(phase === "Creating" || phase === "Pending") && (
1624-
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm z-10">
1625-
<SessionStartingEvents
1587+
<>
1588+
{/* chat -- always mounted, hidden when a file/task tab is active */}
1589+
<div className={cn("relative flex-1 flex flex-col overflow-hidden", !isChatActive && "hidden")}>
1590+
<Card className="relative flex-1 flex flex-col overflow-hidden py-0 border-0 rounded-none">
1591+
<CardContent className="px-6 pt-0 pb-0 flex-1 flex flex-col overflow-hidden">
1592+
{repoChanging && (
1593+
<div className="absolute inset-0 bg-background/90 backdrop-blur-sm z-10 flex items-center justify-center rounded-lg">
1594+
<Alert className="max-w-md mx-4">
1595+
<Loader2 className="h-4 w-4 animate-spin" />
1596+
<AlertTitle>Updating Repositories...</AlertTitle>
1597+
<AlertDescription>
1598+
<p>Please wait while repositories are being updated. This may take 10-20 seconds...</p>
1599+
</AlertDescription>
1600+
</Alert>
1601+
</div>
1602+
)}
1603+
1604+
<div className="relative flex flex-col flex-1 overflow-hidden">
1605+
{(phase === "Creating" || phase === "Pending") && (
1606+
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm z-10">
1607+
<SessionStartingEvents
1608+
projectName={projectName}
1609+
sessionName={sessionName}
1610+
/>
1611+
</div>
1612+
)}
1613+
<FeedbackProvider
16261614
projectName={projectName}
16271615
sessionName={sessionName}
1628-
/>
1616+
username={currentUser?.username || currentUser?.displayName || "anonymous"}
1617+
initialPrompt={session?.spec?.initialPrompt}
1618+
activeWorkflow={workflowManagement.activeWorkflow || undefined}
1619+
messages={streamMessages}
1620+
traceId={langfuseTraceId || undefined}
1621+
messageFeedback={aguiState.messageFeedback}
1622+
>
1623+
<MessagesTab
1624+
session={session}
1625+
streamMessages={streamMessages}
1626+
chatInput={chatInput}
1627+
setChatInput={setChatInput}
1628+
onSendChat={() => Promise.resolve(sendChat())}
1629+
onSendToolAnswer={sendToolAnswer}
1630+
onInterrupt={aguiInterrupt}
1631+
onGoToResults={() => {}}
1632+
onContinue={handleContinue}
1633+
workflowMetadata={workflowMetadata}
1634+
onCommandClick={handleCommandClick}
1635+
isRunActive={isRunActive}
1636+
queuedMessages={sessionQueue.messages}
1637+
hasRealMessages={hasRealMessages}
1638+
onCancelQueuedMessage={sessionQueue.cancelMessage}
1639+
onUpdateQueuedMessage={sessionQueue.updateMessage}
1640+
onClearQueue={sessionQueue.clearMessages}
1641+
agentName={agentName}
1642+
onAddRepository={handleOpenContextModal}
1643+
onUploadFile={handleOpenUploadModal}
1644+
projectName={projectName}
1645+
workflowSlot={
1646+
<WorkflowSelector
1647+
sessionPhase={session?.status?.phase}
1648+
activeWorkflow={workflowManagement.activeWorkflow}
1649+
activeWorkflowDetails={
1650+
workflowManagement.activeWorkflow === "custom" && session?.spec?.activeWorkflow
1651+
? {
1652+
gitUrl: session.spec.activeWorkflow.gitUrl,
1653+
branch: session.spec.activeWorkflow.branch || "main",
1654+
path: session.spec.activeWorkflow.path || "",
1655+
}
1656+
: undefined
1657+
}
1658+
selectedWorkflow={workflowManagement.selectedWorkflow}
1659+
workflowActivating={workflowManagement.workflowActivating}
1660+
ootbWorkflows={ootbWorkflows}
1661+
onWorkflowChange={handleWorkflowChange}
1662+
onLoadCustom={() => setCustomWorkflowDialogOpen(true)}
1663+
/>
1664+
}
1665+
/>
1666+
</FeedbackProvider>
16291667
</div>
1630-
)}
1631-
<FeedbackProvider
1632-
projectName={projectName}
1633-
sessionName={sessionName}
1634-
username={currentUser?.username || currentUser?.displayName || "anonymous"}
1635-
initialPrompt={session?.spec?.initialPrompt}
1636-
activeWorkflow={workflowManagement.activeWorkflow || undefined}
1637-
messages={streamMessages}
1638-
traceId={langfuseTraceId || undefined}
1639-
messageFeedback={aguiState.messageFeedback}
1668+
</CardContent>
1669+
</Card>
1670+
</div>
1671+
1672+
{/* file tabs -- one FileViewer per open tab, only the active one is visible */}
1673+
{fileTabs.openTabs.map((tab) => {
1674+
const isActive = fileTabs.activeTab.type === "file" && fileTabs.activeTab.path === tab.path;
1675+
return (
1676+
<div
1677+
key={tab.path}
1678+
className={cn("flex-1 flex flex-col overflow-hidden", !isActive && "hidden")}
16401679
>
1641-
<MessagesTab
1642-
session={session}
1643-
streamMessages={streamMessages}
1644-
chatInput={chatInput}
1645-
setChatInput={setChatInput}
1646-
onSendChat={() => Promise.resolve(sendChat())}
1647-
onSendToolAnswer={sendToolAnswer}
1648-
onInterrupt={aguiInterrupt}
1649-
onGoToResults={() => {}}
1650-
onContinue={handleContinue}
1651-
workflowMetadata={workflowMetadata}
1652-
onCommandClick={handleCommandClick}
1653-
isRunActive={isRunActive}
1654-
queuedMessages={sessionQueue.messages}
1655-
hasRealMessages={hasRealMessages}
1656-
onCancelQueuedMessage={sessionQueue.cancelMessage}
1657-
onUpdateQueuedMessage={sessionQueue.updateMessage}
1658-
onClearQueue={sessionQueue.clearMessages}
1659-
agentName={agentName}
1660-
onAddRepository={handleOpenContextModal}
1661-
onUploadFile={handleOpenUploadModal}
1680+
<FileViewer
16621681
projectName={projectName}
1663-
workflowSlot={
1664-
<WorkflowSelector
1665-
sessionPhase={session?.status?.phase}
1666-
activeWorkflow={workflowManagement.activeWorkflow}
1667-
activeWorkflowDetails={
1668-
workflowManagement.activeWorkflow === "custom" && session?.spec?.activeWorkflow
1669-
? {
1670-
gitUrl: session.spec.activeWorkflow.gitUrl,
1671-
branch: session.spec.activeWorkflow.branch || "main",
1672-
path: session.spec.activeWorkflow.path || "",
1673-
}
1674-
: undefined
1675-
}
1676-
selectedWorkflow={workflowManagement.selectedWorkflow}
1677-
workflowActivating={workflowManagement.workflowActivating}
1678-
ootbWorkflows={ootbWorkflows}
1679-
onWorkflowChange={handleWorkflowChange}
1680-
onLoadCustom={() => setCustomWorkflowDialogOpen(true)}
1681-
/>
1682-
}
1682+
sessionName={sessionName}
1683+
filePath={tab.path}
1684+
sessionPhase={phase}
1685+
isActive={isActive}
16831686
/>
1684-
</FeedbackProvider>
1685-
</div>
1686-
</CardContent>
1687-
</Card>
1687+
</div>
1688+
);
1689+
})}
1690+
1691+
{/* task tabs -- same pattern */}
1692+
{fileTabs.openTaskTabs.map((tab) => {
1693+
const isActive = fileTabs.activeTab.type === "task" && fileTabs.activeTab.taskId === tab.taskId;
1694+
const task = aguiState.backgroundTasks.get(tab.taskId);
1695+
return (
1696+
<div
1697+
key={tab.taskId}
1698+
className={cn("flex-1 flex flex-col overflow-hidden", !isActive && "hidden")}
1699+
>
1700+
<TaskTranscriptViewer
1701+
projectName={projectName}
1702+
sessionName={sessionName}
1703+
taskId={tab.taskId}
1704+
task={task}
1705+
isActive={isActive}
1706+
/>
1707+
</div>
1708+
);
1709+
})}
1710+
</>
16881711
);
16891712
};
16901713

0 commit comments

Comments
 (0)