Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/contexts/contexts_organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ Contexts for Workstation pages. Each provides session/state management.
| `BrowserContext` | Browser tab sessions |
| `EditorContext` | Editor repo selection |
| `FilesContext` | Document files management |
| `TerminalContext` | Terminal sessions |

Terminal sessions are owned by the workstation terminal atoms rather than a
parallel React context.

### `session/` - Session Contexts

Expand Down Expand Up @@ -101,7 +103,7 @@ Reorganized on 2026-01-29:

- Moved `GitStatusContext/` → `git/GitStatusContext/`
- Moved `MultiRepoGitStatusContext` → `git/`
- Moved `AutomationContext`, `BrowserContext`, `EditorContext`, `FilesContext`, `TerminalContext` → `workstation/`
- Moved `AutomationContext`, `BrowserContext`, `EditorContext`, `FilesContext` → `workstation/`
- Moved `SessionListContext`, `RecentFilesContext` → `session/`
- Moved `ToolbarThemeContext` → `ui/`
- Kept `workspace/` as-is (already organized)
Expand Down
192 changes: 31 additions & 161 deletions src/contexts/workstation/BrowserContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
*
* Performance optimizations:
* - Uses startTransition for non-urgent state updates to avoid blocking UI
* - Defers cascading state updates with queueMicrotask
* - Stores session state in one Jotai source so every close path is coherent
*/
import { useAtomValue, useSetAtom } from "jotai";
import React, {
createContext,
startTransition,
Expand All @@ -17,14 +18,17 @@ import React, {
useRef,
useState,
} from "react";
import { v4 as uuidv4 } from "uuid";

import { useGlobalBrowserTabs } from "@src/hooks/ui/tabs/useGlobalTabs";
import { useSyncBrowserTabs } from "@src/hooks/ui/tabs/useSyncGlobalTabs";
import {
NEW_PRIVATE_TAB_TITLE,
NEW_TAB_TITLE,
} from "@src/store/workstation/browser/tabs";
addBrowserSessionAtom,
browserSessionStateAtom,
closeBrowserSessionAtom,
forceSaveBrowserSessionsAtom,
setActiveBrowserSessionAtom,
updateBrowserSessionAtom,
} from "@src/store/workstation/browser/sessionState";
import type { BrowserSession } from "@src/types/ui/tabs";

interface BrowserContextValue {
Expand All @@ -42,83 +46,16 @@ interface BrowserContextValue {

const BrowserContext = createContext<BrowserContextValue | null>(null);

// Helper function to extract title from URL
const getTitleFromUrl = (url: string): string => {
if (!url) return NEW_TAB_TITLE;
try {
const urlObj = new URL(url);
return urlObj.hostname || NEW_TAB_TITLE;
} catch {
return NEW_TAB_TITLE;
}
};

// Browser sessions are the durable source for live browser resources. The
// WorkStation `browserTabsAtom` is a shared-resource projection synchronized
// from this state; it must never be used as a second persistence owner.
const BROWSER_SESSIONS_STORAGE_KEY = "browser-explorer-sessions";

// Load sessions from localStorage
const loadFromStorage = (): {
sessions: BrowserSession[];
activeSessionId: string;
} | null => {
try {
const stored = localStorage.getItem(BROWSER_SESSIONS_STORAGE_KEY);
const parsed = stored ? JSON.parse(stored) : null;
const sessions = Array.isArray(parsed?.sessions) ? parsed.sessions : [];
const activeSessionId =
typeof parsed?.activeSessionId === "string" ? parsed.activeSessionId : "";
if (sessions.length > 0) {
const validActiveSessionId = sessions.some(
(session: BrowserSession) => session.id === activeSessionId
)
? activeSessionId
: (sessions[0]?.id ?? "");
return { sessions, activeSessionId: validActiveSessionId };
}
} catch {
return null;
}
return null;
};

// Save sessions to localStorage
const saveToStorage = (sessions: BrowserSession[], activeSessionId: string) => {
try {
localStorage.setItem(
BROWSER_SESSIONS_STORAGE_KEY,
JSON.stringify({ sessions, activeSessionId })
);
} catch {
// Ignore storage errors
}
};

// Default initial state for browser tab - starts empty like CodeEditor
const getDefaultState = (): {
sessions: BrowserSession[];
activeSessionId: string;
filterValue: string;
} => {
// Try to load from localStorage first
const stored = loadFromStorage();
if (stored) {
return { ...stored, filterValue: "" };
}

// No default session - user clicks + to create tabs
return {
sessions: [],
activeSessionId: "",
filterValue: "",
};
};

export const BrowserProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const { removeBrowserTab } = useGlobalBrowserTabs();
const { sessions, activeSessionId } = useAtomValue(browserSessionStateAtom);
const addBrowserSession = useSetAtom(addBrowserSessionAtom);
const setActiveBrowserSession = useSetAtom(setActiveBrowserSessionAtom);
const closeBrowserSession = useSetAtom(closeBrowserSessionAtom);
const updateBrowserSession = useSetAtom(updateBrowserSessionAtom);
const forceSaveBrowserSessions = useSetAtom(forceSaveBrowserSessionsAtom);

const sessionsRef = useRef<BrowserSession[]>([]);
const removeBrowserTabRef = useRef(removeBrowserTab);
Expand All @@ -128,12 +65,6 @@ export const BrowserProvider: React.FC<{ children: React.ReactNode }> = ({
removeBrowserTabRef.current = removeBrowserTab;
}, [removeBrowserTab]);

const [sessions, setSessions] = useState<BrowserSession[]>(
() => getDefaultState().sessions
);
const [activeSessionId, setActiveSessionId] = useState<string>(
() => getDefaultState().activeSessionId
);
const [filterValue, setFilterValue] = useState<string>("");

// Keep sessionsRef up to date
Expand All @@ -154,107 +85,46 @@ export const BrowserProvider: React.FC<{ children: React.ReactNode }> = ({
// ✨ Sync to global tabs state (for components that use navigationSidebarTabsAtom)
useSyncBrowserTabs(sessions, activeSessionId);

// Ensure active session exists (or is empty if no sessions)
useEffect(() => {
const activeSessionExists = sessions.some(
(session) => session.id === activeSessionId
);
if (!activeSessionExists) {
// Use startTransition to avoid blocking UI during correction
startTransition(() => {
setActiveSessionId(sessions.length > 0 ? sessions[0].id : "");
});
}
}, [sessions, activeSessionId]);

// Persist state to localStorage
useEffect(() => {
if (sessions.length > 0) {
saveToStorage(sessions, activeSessionId);
} else {
// Clear storage when all sessions are closed
localStorage.removeItem(BROWSER_SESSIONS_STORAGE_KEY);
}
}, [sessions, activeSessionId]);

// Add a new session
const handleAddSession = useCallback((url?: string, incognito = false) => {
const newSessionId = uuidv4();
const newSession: BrowserSession = {
id: newSessionId,
title: url
? getTitleFromUrl(url)
: incognito
? NEW_PRIVATE_TAB_TITLE
: NEW_TAB_TITLE,
url: url || "",
history: url ? [url] : [],
historyIndex: url ? 0 : -1,
historyEntries: url
? [{ url, title: getTitleFromUrl(url), visitedAt: Date.now() }]
: [],
isLoading: false,
error: null,
incognito,
};

// Keep session list + active id in the same update. Deferring only setSessions
// (e.g. via startTransition) while setting activeSessionId eagerly lets the
// "ensure active session exists" effect run with the new id before the new
// row exists and resets focus to sessions[0].
setSessions((prev) => [...prev, newSession]);
setActiveSessionId(newSessionId);
return newSessionId;
}, []);
const handleAddSession = useCallback(
(url?: string, incognito = false) => addBrowserSession({ url, incognito }),
[addBrowserSession]
);

// Switch to a session
const handleSessionClick = useCallback((sessionId: string) => {
setActiveSessionId(sessionId);
}, []);
const handleSessionClick = useCallback(
(sessionId: string) => {
setActiveBrowserSession(sessionId);
},
[setActiveBrowserSession]
);

// Close a session
const handleCloseSession = useCallback(
(sessionId: string) => {
// Use startTransition to avoid blocking UI during state update
startTransition(() => {
setSessions((prev) => {
const filtered = prev.filter((session) => session.id !== sessionId);

// If closing the active session, activate the first remaining session (or clear if none)
if (sessionId === activeSessionId) {
if (filtered.length > 0) {
setActiveSessionId(filtered[0].id);
} else {
setActiveSessionId("");
}
}

return filtered;
});
closeBrowserSession(sessionId);
});
},
[activeSessionId]
[closeBrowserSession]
);

// Update a specific session
const updateSession = useCallback(
(sessionId: string, updates: Partial<BrowserSession>) => {
// Use startTransition for non-urgent updates (like URL/title changes)
startTransition(() => {
setSessions((prev) => {
return prev.map((session) =>
session.id === sessionId ? { ...session, ...updates } : session
);
});
updateBrowserSession({ sessionId, updates });
});
},
[]
[updateBrowserSession]
);

// Force save to localStorage (for when switching away from browser mode)
const forceSave = useCallback(() => {
saveToStorage(sessions, activeSessionId);
}, [sessions, activeSessionId]);
forceSaveBrowserSessions();
}, [forceSaveBrowserSessions]);

const value = useMemo<BrowserContextValue>(
() => ({
Expand Down
Loading
Loading