@@ -6,6 +6,7 @@ import { GlobalFonts } from "./styles/fonts";
66import { GlobalScrollbars } from "./styles/scrollbars" ;
77import type { ProjectConfig } from "./config" ;
88import type { WorkspaceSelection } from "./components/ProjectSidebar" ;
9+ import type { FrontendWorkspaceMetadata } from "./types/workspace" ;
910import { LeftSidebar } from "./components/LeftSidebar" ;
1011import NewWorkspaceModal from "./components/NewWorkspaceModal" ;
1112import { AIView } from "./components/AIView" ;
@@ -172,12 +173,17 @@ function AppInner() {
172173 [ setProjects ]
173174 ) ;
174175
175- const { workspaceMetadata, createWorkspace, removeWorkspace, renameWorkspace } =
176- useWorkspaceManagement ( {
177- selectedWorkspace,
178- onProjectsUpdate : handleProjectsUpdate ,
179- onSelectedWorkspaceUpdate : setSelectedWorkspace ,
180- } ) ;
176+ const {
177+ workspaceMetadata,
178+ loading : metadataLoading ,
179+ createWorkspace,
180+ removeWorkspace,
181+ renameWorkspace,
182+ } = useWorkspaceManagement ( {
183+ selectedWorkspace,
184+ onProjectsUpdate : handleProjectsUpdate ,
185+ onSelectedWorkspaceUpdate : setSelectedWorkspace ,
186+ } ) ;
181187
182188 // NEW: Sync workspace metadata with the stores
183189 const workspaceStore = useWorkspaceStoreRaw ( ) ;
@@ -215,8 +221,10 @@ function AppInner() {
215221 window . history . replaceState ( null , "" , newHash ) ;
216222 }
217223
218- // Update window title
219- const title = `${ selectedWorkspace . workspaceId } - ${ selectedWorkspace . projectName } - cmux` ;
224+ // Update window title with workspace name
225+ const workspaceName =
226+ workspaceMetadata . get ( selectedWorkspace . workspaceId ) ?. name ?? selectedWorkspace . workspaceId ;
227+ const title = `${ workspaceName } - ${ selectedWorkspace . projectName } - cmux` ;
220228 void window . api . window . setTitle ( title ) ;
221229 } else {
222230 // Clear hash when no workspace selected
@@ -225,42 +233,80 @@ function AppInner() {
225233 }
226234 void window . api . window . setTitle ( "cmux" ) ;
227235 }
228- } , [ selectedWorkspace ] ) ;
236+ } , [ selectedWorkspace , workspaceMetadata ] ) ;
229237
230238 // Restore workspace from URL on mount (if valid)
239+ // This effect runs once on mount to restore from hash, which takes priority over localStorage
240+ const [ hasRestoredFromHash , setHasRestoredFromHash ] = useState ( false ) ;
241+
231242 useEffect ( ( ) => {
243+ // Only run once
244+ if ( hasRestoredFromHash ) return ;
245+
246+ // Wait for metadata to finish loading
247+ if ( metadataLoading ) return ;
248+
232249 const hash = window . location . hash ;
233250 if ( hash . startsWith ( "#workspace=" ) ) {
234251 const workspaceId = decodeURIComponent ( hash . substring ( "#workspace=" . length ) ) ;
235252
236253 // Find workspace in metadata
237- const metadata = Array . from ( workspaceMetadata . values ( ) ) . find ( ( ws ) => ws . id === workspaceId ) ;
254+ const metadata = workspaceMetadata . get ( workspaceId ) ;
238255
239256 if ( metadata ) {
240- // Find project for this workspace
241- for ( const [ projectPath , projectConfig ] of projects . entries ( ) ) {
242- const workspace = ( projectConfig . workspaces ?? [ ] ) . find (
243- ( ws ) => ws . path === metadata . workspacePath
244- ) ;
245- if ( workspace ) {
246- setSelectedWorkspace ( {
247- workspaceId : metadata . id ,
248- projectPath,
249- projectName : metadata . projectName ,
250- workspacePath : metadata . workspacePath ,
251- } ) ;
252- break ;
253- }
257+ // Restore from hash (overrides localStorage)
258+ setSelectedWorkspace ( {
259+ workspaceId : metadata . id ,
260+ projectPath : metadata . projectPath ,
261+ projectName : metadata . projectName ,
262+ namedWorkspacePath : metadata . namedWorkspacePath ,
263+ } ) ;
264+ }
265+ }
266+
267+ setHasRestoredFromHash ( true ) ;
268+ } , [ metadataLoading , workspaceMetadata , hasRestoredFromHash , setSelectedWorkspace ] ) ;
269+
270+ // Validate selected workspace exists and has all required fields
271+ useEffect ( ( ) => {
272+ // Don't validate until metadata is loaded
273+ if ( metadataLoading ) return ;
274+
275+ if ( selectedWorkspace ) {
276+ const metadata = workspaceMetadata . get ( selectedWorkspace . workspaceId ) ;
277+
278+ if ( ! metadata ) {
279+ // Workspace was deleted
280+ console . warn (
281+ `Workspace ${ selectedWorkspace . workspaceId } no longer exists, clearing selection`
282+ ) ;
283+ setSelectedWorkspace ( null ) ;
284+ if ( window . location . hash ) {
285+ window . history . replaceState ( null , "" , window . location . pathname ) ;
254286 }
287+ } else if ( ! selectedWorkspace . namedWorkspacePath && metadata . namedWorkspacePath ) {
288+ // Old localStorage entry missing namedWorkspacePath - update it once
289+ console . log ( `Updating workspace ${ selectedWorkspace . workspaceId } with missing fields` ) ;
290+ setSelectedWorkspace ( {
291+ workspaceId : metadata . id ,
292+ projectPath : metadata . projectPath ,
293+ projectName : metadata . projectName ,
294+ namedWorkspacePath : metadata . namedWorkspacePath ,
295+ } ) ;
255296 }
256297 }
257- // Only run on mount
258- // eslint-disable-next-line react-hooks/exhaustive-deps
259- } , [ ] ) ;
298+ } , [ metadataLoading , selectedWorkspace , workspaceMetadata , setSelectedWorkspace ] ) ;
260299
261- const openWorkspaceInTerminal = useCallback ( ( workspacePath : string ) => {
262- void window . api . workspace . openTerminal ( workspacePath ) ;
263- } , [ ] ) ;
300+ const openWorkspaceInTerminal = useCallback (
301+ ( workspaceId : string ) => {
302+ // Look up workspace metadata to get the named path (user-friendly symlink)
303+ const metadata = workspaceMetadata . get ( workspaceId ) ;
304+ if ( metadata ) {
305+ void window . api . workspace . openTerminal ( metadata . namedWorkspacePath ) ;
306+ }
307+ } ,
308+ [ workspaceMetadata ]
309+ ) ;
264310
265311 const handleRemoveProject = useCallback (
266312 async ( path : string ) => {
@@ -364,33 +410,39 @@ function AppInner() {
364410 const workspaceRecency = useWorkspaceRecency ( ) ;
365411
366412 // Sort workspaces by recency (most recent first)
413+ // Returns Map<projectPath, FrontendWorkspaceMetadata[]> for direct component use
367414 // Use stable reference to prevent sidebar re-renders when sort order hasn't changed
368415 const sortedWorkspacesByProject = useStableReference (
369416 ( ) => {
370- const result = new Map < string , ProjectConfig [ "workspaces" ] > ( ) ;
417+ const result = new Map < string , FrontendWorkspaceMetadata [ ] > ( ) ;
371418 for ( const [ projectPath , config ] of projects ) {
372- result . set (
373- projectPath ,
374- ( config . workspaces ?? [ ] ) . slice ( ) . sort ( ( a , b ) => {
375- const aMeta = workspaceMetadata . get ( a . path ) ;
376- const bMeta = workspaceMetadata . get ( b . path ) ;
377- if ( ! aMeta || ! bMeta ) return 0 ;
378-
379- // Get timestamp of most recent user message (0 if never used)
380- const aTimestamp = workspaceRecency [ aMeta . id ] ?? 0 ;
381- const bTimestamp = workspaceRecency [ bMeta . id ] ?? 0 ;
382- return bTimestamp - aTimestamp ;
383- } )
384- ) ;
419+ // Transform Workspace[] to FrontendWorkspaceMetadata[] using workspace ID
420+ const metadataList = config . workspaces
421+ . map ( ( ws ) => ( ws . id ? workspaceMetadata . get ( ws . id ) : undefined ) )
422+ . filter ( ( meta ) : meta is FrontendWorkspaceMetadata => meta !== undefined && meta !== null ) ;
423+
424+ // Sort by recency
425+ metadataList . sort ( ( a , b ) => {
426+ const aTimestamp = workspaceRecency [ a . id ] ?? 0 ;
427+ const bTimestamp = workspaceRecency [ b . id ] ?? 0 ;
428+ return bTimestamp - aTimestamp ;
429+ } ) ;
430+
431+ result . set ( projectPath , metadataList ) ;
385432 }
386433 return result ;
387434 } ,
388435 ( prev , next ) => {
389- // Compare Maps: check if both size and workspace order are the same
436+ // Compare Maps: check if size, workspace order, and metadata content are the same
390437 if (
391438 ! compareMaps ( prev , next , ( a , b ) => {
392439 if ( a . length !== b . length ) return false ;
393- return a . every ( ( workspace , i ) => workspace . path === b [ i ] . path ) ;
440+ // Check both ID and name to detect renames
441+ return a . every ( ( metadata , i ) => {
442+ const bMeta = b [ i ] ;
443+ if ( ! bMeta || ! metadata ) return false ; // Null-safe
444+ return metadata . id === bMeta . id && metadata . name === bMeta . name ;
445+ } ) ;
394446 } )
395447 ) {
396448 return false ;
@@ -410,7 +462,7 @@ function AppInner() {
410462
411463 // Find current workspace index in sorted list
412464 const currentIndex = sortedWorkspaces . findIndex (
413- ( ws ) => ws . path === selectedWorkspace . workspacePath
465+ ( metadata ) => metadata . id === selectedWorkspace . workspaceId
414466 ) ;
415467 if ( currentIndex === - 1 ) return ;
416468
@@ -422,20 +474,17 @@ function AppInner() {
422474 targetIndex = currentIndex === 0 ? sortedWorkspaces . length - 1 : currentIndex - 1 ;
423475 }
424476
425- const targetWorkspace = sortedWorkspaces [ targetIndex ] ;
426- if ( ! targetWorkspace ) return ;
427-
428- const metadata = workspaceMetadata . get ( targetWorkspace . path ) ;
429- if ( ! metadata ) return ;
477+ const targetMetadata = sortedWorkspaces [ targetIndex ] ;
478+ if ( ! targetMetadata ) return ;
430479
431480 setSelectedWorkspace ( {
432481 projectPath : selectedWorkspace . projectPath ,
433482 projectName : selectedWorkspace . projectName ,
434- workspacePath : targetWorkspace . path ,
435- workspaceId : metadata . id ,
483+ namedWorkspacePath : targetMetadata . namedWorkspacePath ,
484+ workspaceId : targetMetadata . id ,
436485 } ) ;
437486 } ,
438- [ selectedWorkspace , sortedWorkspacesByProject , workspaceMetadata , setSelectedWorkspace ]
487+ [ selectedWorkspace , sortedWorkspacesByProject , setSelectedWorkspace ]
439488 ) ;
440489
441490 // Register command sources with registry
@@ -534,12 +583,7 @@ function AppInner() {
534583 ) ;
535584
536585 const selectWorkspaceFromPalette = useCallback (
537- ( selection : {
538- projectPath : string ;
539- projectName : string ;
540- workspacePath : string ;
541- workspaceId : string ;
542- } ) => {
586+ ( selection : WorkspaceSelection ) => {
543587 setSelectedWorkspace ( selection ) ;
544588 } ,
545589 [ setSelectedWorkspace ]
@@ -679,20 +723,19 @@ function AppInner() {
679723 />
680724 < MainContent >
681725 < ContentArea >
682- { selectedWorkspace ?. workspacePath ? (
726+ { selectedWorkspace ? (
683727 < ErrorBoundary
684- workspaceInfo = { `${ selectedWorkspace . projectName } /${ selectedWorkspace . workspacePath ?. split ( "/" ) . pop ( ) ?? selectedWorkspace . workspaceId ?? "unknown" } ` }
728+ workspaceInfo = { `${ selectedWorkspace . projectName } /${ selectedWorkspace . namedWorkspacePath ?. split ( "/" ) . pop ( ) ?? selectedWorkspace . workspaceId } ` }
685729 >
686730 < AIView
687731 key = { selectedWorkspace . workspaceId }
688732 workspaceId = { selectedWorkspace . workspaceId }
689733 projectName = { selectedWorkspace . projectName }
690734 branch = {
691- selectedWorkspace . workspacePath ?. split ( "/" ) . pop ( ) ??
692- selectedWorkspace . workspaceId ??
693- ""
735+ selectedWorkspace . namedWorkspacePath ?. split ( "/" ) . pop ( ) ??
736+ selectedWorkspace . workspaceId
694737 }
695- workspacePath = { selectedWorkspace . workspacePath }
738+ namedWorkspacePath = { selectedWorkspace . namedWorkspacePath ?? "" }
696739 />
697740 </ ErrorBoundary >
698741 ) : (
0 commit comments