diff --git a/front/components/assistant/AgentMessageMarkdown.integration.test.tsx b/front/components/assistant/AgentMessageMarkdown.integration.test.tsx index 8e66e3f2f4b4..fbceb34e650c 100644 --- a/front/components/assistant/AgentMessageMarkdown.integration.test.tsx +++ b/front/components/assistant/AgentMessageMarkdown.integration.test.tsx @@ -117,6 +117,8 @@ describe("AgentMessageMarkdown - Integration Tests", () => { const { container } = render( ({ ...(await importOriginal()), useConversationSidePanelContext: () => ({ + canGoBack: false, + goBack: vi.fn(), currentPanel: undefined, isPanelClosing: false, openPanel: vi.fn(), diff --git a/front/components/assistant/conversation/ConversationSidePanelContext.test.tsx b/front/components/assistant/conversation/ConversationSidePanelContext.test.tsx index 18bf25dd0d03..0d7b02d2db44 100644 --- a/front/components/assistant/conversation/ConversationSidePanelContext.test.tsx +++ b/front/components/assistant/conversation/ConversationSidePanelContext.test.tsx @@ -96,6 +96,80 @@ describe("ConversationSidePanelProvider selection", () => { }); }); +describe("ConversationSidePanelProvider history", () => { + beforeEach(resetHash); + + it("starts with no history", () => { + const probe = renderProvider(); + + expect(probe.panel.canGoBack).toBe(false); + }); + + it("goes back to the previously shown panel", () => { + const probe = renderProvider(); + + act(() => probe.panel.openPanel({ type: "files" })); + expect(probe.panel.canGoBack).toBe(false); + + act(() => + probe.panel.openPanel({ type: "file_preview", filePath: "a.md" }) + ); + expect(probe.panel.canGoBack).toBe(true); + + act(() => probe.panel.goBack()); + expect(probe.panel.currentPanel).toBe("files"); + expect(probe.panel.data).toBe("files"); + expect(probe.panel.canGoBack).toBe(false); + }); + + it("walks back through several panels in order", () => { + const probe = renderProvider(); + + act(() => probe.panel.openPanel({ type: "files" })); + act(() => + probe.panel.openPanel({ type: "file_preview", filePath: "a.md" }) + ); + act(() => + probe.panel.openPanel({ type: "file_preview", filePath: "b.md" }) + ); + + act(() => probe.panel.goBack()); + expect(probe.panel.data).toBe("a.md"); + + act(() => probe.panel.goBack()); + expect(probe.panel.currentPanel).toBe("files"); + expect(probe.panel.canGoBack).toBe(false); + }); + + it("does not record a history entry when reselecting the same content", () => { + const probe = renderProvider(); + + act(() => + probe.panel.openPanel({ type: "file_preview", filePath: "a.md" }) + ); + act(() => + probe.panel.openPanel({ type: "file_preview", filePath: "a.md" }) + ); + + expect(probe.panel.canGoBack).toBe(false); + }); + + it("clears history once the panel has closed", () => { + const probe = renderProvider(); + + act(() => probe.panel.openPanel({ type: "files" })); + act(() => + probe.panel.openPanel({ type: "file_preview", filePath: "a.md" }) + ); + expect(probe.panel.canGoBack).toBe(true); + + act(() => probe.panel.closePanel()); + act(() => probe.panel.onPanelClosed()); + + expect(probe.panel.canGoBack).toBe(false); + }); +}); + describe("ConversationSidePanelProvider toggle", () => { beforeEach(resetHash); diff --git a/front/components/assistant/conversation/ConversationSidePanelContext.tsx b/front/components/assistant/conversation/ConversationSidePanelContext.tsx index 3bc0cc722033..77cd3efd00ee 100644 --- a/front/components/assistant/conversation/ConversationSidePanelContext.tsx +++ b/front/components/assistant/conversation/ConversationSidePanelContext.tsx @@ -66,11 +66,13 @@ const isSupportedPanelType = ( type === "skill"; interface ConversationSidePanelContextType { + canGoBack: boolean; currentPanel: ConversationSidePanelType; // True between closePanel() and the end of the collapse transition. `currentPanel` keeps the // old value meanwhile so the panel content does not flicker; toggles read this to unselect // right away. isPanelClosing: boolean; + goBack: () => void; openPanel: (params: OpenPanelParams) => void; togglePanel: (params: OpenPanelParams) => void; closePanel: () => void; @@ -163,6 +165,13 @@ function getPanelData(params: OpenPanelParams): string { } } +interface PanelHistoryEntry { + panel: ConversationSidePanelType; + data: string; +} + +const MAX_PANEL_HISTORY = 50; + interface ConversationSidePanelProviderProps { children: React.ReactNode; } @@ -179,6 +188,9 @@ export function ConversationSidePanelProvider({ const previousConversationIdRef = React.useRef(activeConversationId); const panelRef = React.useRef(null); + const [panelHistory, setPanelHistory] = React.useState( + [] + ); const [isPanelClosing, setIsPanelClosing] = React.useState(false); const [virtuosoMsg, setVirtuosoMsg] = React.useState(null); @@ -196,6 +208,7 @@ export function ConversationSidePanelProvider({ setIsPanelClosing(false); setData(undefined); setCurrentPanel(undefined); + setPanelHistory([]); }, [setData, setCurrentPanel]); // biome-ignore lint/correctness/useExhaustiveDependencies: ignored using `--suppress` @@ -226,6 +239,17 @@ export function ConversationSidePanelProvider({ return; } + if ( + !isSameContent && + !isPanelClosing && + data && + isSupportedPanelType(currentPanel) + ) { + setPanelHistory((history) => + [...history, { panel: currentPanel, data }].slice(-MAX_PANEL_HISTORY) + ); + } + setIsPanelClosing(false); setCurrentPanel(params.type); setData(nextData); @@ -251,6 +275,20 @@ export function ConversationSidePanelProvider({ ] ); + const goBack = useCallback(() => { + const previous = panelHistory.at(-1); + if (!previous) { + return; + } + + setPanelHistory((history) => history.slice(0, -1)); + setIsPanelClosing(false); + setCurrentPanel(previous.panel); + setData(previous.data); + setFullScreenHash(undefined); + panelRef.current?.expand(getDefaultRightPanelSize(previous.panel)); + }, [panelHistory, setCurrentPanel, setData, setFullScreenHash]); + // Idempotent open for programmatic callers: a toggle could mis-close during a close→reopen // transition where `currentPanel` still reads the old value. const openPanel = useCallback( @@ -293,6 +331,8 @@ export function ConversationSidePanelProvider({ const value = useMemo( () => ({ + canGoBack: panelHistory.length > 0, + goBack, currentPanel: isSupportedPanelType(currentPanel) ? currentPanel : undefined, @@ -308,6 +348,8 @@ export function ConversationSidePanelProvider({ data, }), [ + panelHistory, + goBack, currentPanel, isPanelClosing, openPanel, diff --git a/front/components/assistant/conversation/ConversationSidePanelHeader.tsx b/front/components/assistant/conversation/ConversationSidePanelHeader.tsx index 52da1912badb..9a1e081d2696 100644 --- a/front/components/assistant/conversation/ConversationSidePanelHeader.tsx +++ b/front/components/assistant/conversation/ConversationSidePanelHeader.tsx @@ -1,5 +1,6 @@ +import { useConversationSidePanelContext } from "@app/components/assistant/conversation/ConversationSidePanelContext"; import { AppLayoutTitle } from "@app/components/sparkle/AppLayoutTitle"; -import { Button, XClose } from "@dust-tt/sparkle"; +import { ArrowLeft, Button, XClose } from "@dust-tt/sparkle"; import type React from "react"; interface ConversationSidePanelHeaderProps { @@ -11,9 +12,20 @@ export function ConversationSidePanelHeader({ children, onClose, }: ConversationSidePanelHeaderProps) { + const { canGoBack, goBack } = useConversationSidePanelContext(); + return (
+ {canGoBack && ( +