@@ -2,6 +2,7 @@ import type { SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contr
22import { useLocation } from "@remix-run/react" ;
33import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
44import {
5+ collapsibleHandleClassName ,
56 ResizableHandle ,
67 ResizablePanel ,
78 ResizablePanelGroup ,
@@ -10,16 +11,16 @@ import { useEnvironment } from "~/hooks/useEnvironment";
1011import { useOrganization } from "~/hooks/useOrganizations" ;
1112import { useProject } from "~/hooks/useProject" ;
1213import { useShortcutKeys } from "~/hooks/useShortcutKeys" ;
14+ import { useUser } from "~/hooks/useUser" ;
1315import { useAskAiAvailability } from "~/hooks/useAskAiAvailability" ;
1416import { agentDeepLinkParams , ASK_AI_SHORTCUT , askAiChannelTarget } from "./ask-ai-channels" ;
1517import { DashboardAgentPanel } from "./DashboardAgentPanel" ;
1618import { DashboardAgentProvider , TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher" ;
1719import { useDashboardAgentOpenRequests } from "./dashboardAgentOpenRequest" ;
1820import {
1921 agentHiddenContentClassName ,
20- agentTakeoverClassName ,
21- readAgentFullscreen ,
22- writeAgentFullscreen ,
22+ FloatingAgentWindow ,
23+ useAgentPanelMode ,
2324} from "./panel-layout" ;
2425import { nextPendingTurnChatId } from "./pending-turn" ;
2526import { nextVisibleChat } from "./unread-counts" ;
@@ -35,6 +36,10 @@ import {
3536
3637const TOASTED_WAKES_STORAGE_KEY = "tdev:dashboard-agent:toasted-wakes" ;
3738
39+ // Superseded by the account preference; a stray value here would otherwise pin the mode
40+ // forever if this cleanup effect never ran.
41+ const STALE_MODE_STORAGE_KEYS = [ "tdev:dashboard-agent:mode" , "tdev:dashboard-agent:fullscreen" ] ;
42+
3843// Shorter than the poll interval, so a stuck request is dropped before the next tick.
3944const UNREAD_REQUEST_TIMEOUT_MS = 30_000 ;
4045
@@ -60,6 +65,8 @@ export function DashboardAgent({
6065 const organization = useOrganization ( ) ;
6166 const project = useProject ( ) ;
6267 const environment = useEnvironment ( ) ;
68+ const user = useUser ( ) ;
69+ const modePreference = user . dashboardPreferences . chatOpenMode ;
6370 const actionPath = `/resources/orgs/${ organization . slug } /projects/${ project . slug } /env/${ environment . slug } /dashboard-agent` ;
6471
6572 const [ open , setOpen ] = useState ( false ) ;
@@ -114,14 +121,21 @@ export function DashboardAgent({
114121 setUnreadWakes ( initialUnreadWakes ) ;
115122 setUnreadWork ( initialUnreadWork ) ;
116123 } , [ environment . id , initialUnreadWakes , initialUnreadWork ] ) ;
117- // Read lazily so SSR always renders the side panel.
118- const [ fullscreen , setFullscreen ] = useState ( readAgentFullscreen ) ;
124+ // Every open starts from the account preference; in-chat switches (toggle, drag-to-dock)
125+ // are transient and never write it back.
126+ const { mode, changeMode, resetToPreference, revertFullscreen } = useAgentPanelMode (
127+ modePreference ,
128+ open
129+ ) ;
130+ const fullscreen = mode === "fullscreen" ;
119131
120- const toggleFullscreen = useCallback ( ( ) => {
121- setFullscreen ( ( current ) => {
122- writeAgentFullscreen ( ! current ) ;
123- return ! current ;
124- } ) ;
132+ // Superseded localStorage keys; harmless to skip if storage is unavailable.
133+ useEffect ( ( ) => {
134+ try {
135+ for ( const key of STALE_MODE_STORAGE_KEYS ) window . localStorage . removeItem ( key ) ;
136+ } catch {
137+ /* ignore */
138+ }
125139 } , [ ] ) ;
126140
127141 // Pathname only: filter and search-param changes must keep fullscreen.
@@ -130,11 +144,8 @@ export function DashboardAgent({
130144 useEffect ( ( ) => {
131145 if ( previousPathname . current === pathname ) return ;
132146 previousPathname . current = pathname ;
133- setFullscreen ( ( current ) => {
134- if ( current ) writeAgentFullscreen ( false ) ;
135- return false ;
136- } ) ;
137- } , [ pathname ] ) ;
147+ revertFullscreen ( ) ;
148+ } , [ pathname , revertFullscreen ] ) ;
138149 const [ newChatSeq , setNewChatSeq ] = useState ( 0 ) ;
139150 const [ requestedMessage , setRequestedMessage ] = useState <
140151 { text : string ; seq : number } | undefined
@@ -165,13 +176,14 @@ export function DashboardAgent({
165176 setOpen ( false ) ;
166177 // Pending requests must be dropped or a stale one re-applies on the next open.
167178 visibleChat . current = null ;
168- setFullscreen ( false ) ;
169- writeAgentFullscreen ( false ) ;
179+ // Any transient in-chat mode switch applied only until close; the next open
180+ // starts from the account preference again.
181+ resetToPreference ( ) ;
170182 setRequestedMessage ( undefined ) ;
171183 setOpenChatRequest ( undefined ) ;
172184 setWatchRequest ( undefined ) ;
173185 } ,
174- [ openPanel ]
186+ [ openPanel , resetToPreference ]
175187 ) ;
176188
177189 const openChat = useCallback (
@@ -346,7 +358,10 @@ export function DashboardAgent({
346358 return (
347359 < DashboardAgentProvider value = { context } >
348360 { open ? (
349- // `relative` is the takeover's containing block.
361+ // `relative` is the fullscreen takeover's containing block. The ResizablePanelGroup
362+ // stays mounted across all three modes — only its sizing/handle degenerate outside
363+ // rightPanel — so `FloatingAgentWindow` (and the chat panel inside it) sits at the
364+ // same tree position in every mode and a mode switch never remounts it.
350365 < div className = "relative h-full min-h-0" >
351366 < ResizablePanelGroup
352367 orientation = "horizontal"
@@ -358,25 +373,42 @@ export function DashboardAgent({
358373 </ ResizablePanel >
359374 < ResizableHandle
360375 id = "dashboard-agent-handle"
361- className = { fullscreen ? "invisible" : undefined }
376+ size = { mode === "rightPanel" ? "3px" : "0px" }
377+ className = { collapsibleHandleClassName ( mode === "rightPanel" ) }
362378 />
363- < ResizablePanel id = "dashboard-agent-panel" default = "380px" min = "320px" max = "720px" >
364- < div className = { agentTakeoverClassName ( fullscreen ) } >
365- < DashboardAgentPanel
366- onClose = { ( ) => setPanelOpen ( false ) }
367- requestedMessage = { requestedMessage }
368- openChatRequest = { openChatRequest }
369- watchRequest = { watchRequest }
370- newChatSeq = { newChatSeq }
371- promotedPrompt = { promotedPrompt }
372- onChatRead = { markChatRead }
373- // The panel's own count, off the chat list it has already marked read.
374- onUnreadWorkChange = { setUnreadWork }
375- onTurnActivityChange = { handleTurnActivityChange }
376- isFullscreen = { fullscreen }
377- onToggleFullscreen = { toggleFullscreen }
378- />
379- </ div >
379+ < ResizablePanel
380+ id = "dashboard-agent-panel"
381+ default = "380px"
382+ min = "320px"
383+ max = "720px"
384+ collapsible
385+ collapsed = { mode !== "rightPanel" }
386+ collapsedSize = "0px"
387+ // Non-rightPanel modes render through position:fixed/absolute, which must
388+ // escape this panel's own clipping box to avoid being cut to its 0px width.
389+ // Tailwind v4's important modifier is a trailing `!`, not a leading one.
390+ className = { mode === "rightPanel" ? undefined : "overflow-visible!" }
391+ >
392+ < FloatingAgentWindow mode = { mode } onRequestModeChange = { changeMode } >
393+ { ( { dragHandleProps, dragHandleClassName } ) => (
394+ < DashboardAgentPanel
395+ onClose = { ( ) => setPanelOpen ( false ) }
396+ requestedMessage = { requestedMessage }
397+ openChatRequest = { openChatRequest }
398+ watchRequest = { watchRequest }
399+ newChatSeq = { newChatSeq }
400+ promotedPrompt = { promotedPrompt }
401+ onChatRead = { markChatRead }
402+ // The panel's own count, off the chat list it has already marked read.
403+ onUnreadWorkChange = { setUnreadWork }
404+ onTurnActivityChange = { handleTurnActivityChange }
405+ mode = { mode }
406+ onModeChange = { changeMode }
407+ dragHandleProps = { dragHandleProps }
408+ dragHandleClassName = { dragHandleClassName }
409+ />
410+ ) }
411+ </ FloatingAgentWindow >
380412 </ ResizablePanel >
381413 </ ResizablePanelGroup >
382414 </ div >
0 commit comments