Skip to content
Merged
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
112 changes: 102 additions & 10 deletions src/browser/components/AppLoader/AppLoader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef, type ReactNode } from "react";
import { AnimatePresence, motion } from "motion/react";
import App from "../../App";
import { AuthTokenModal } from "../AuthTokenModal/AuthTokenModal";
Expand All @@ -18,8 +18,96 @@ import { APIProvider, useAPI, type APIClient } from "@/browser/contexts/API";
import { WorkspaceProvider, useWorkspaceContext } from "../../contexts/WorkspaceContext";
import { RouterProvider } from "../../contexts/RouterContext";
import { TelemetryEnabledProvider } from "../../contexts/TelemetryEnabledContext";
import {
hydrateUserPreferencesLocalCache,
UserPreferencesProvider,
} from "@/browser/contexts/UserPreferencesContext";
import { TerminalRouterProvider } from "../../terminal/TerminalRouterContext";

const USER_PREFERENCES_BOOTSTRAP_TIMEOUT_MS = 2000;

function UserPreferencesStartupGate(props: { children: ReactNode }) {
const apiState = useAPI();
const [ready, setReady] = useState(false);
const bootstrappedRef = useRef(false);

useEffect(() => {
if (bootstrappedRef.current || !apiState.api) {
return;
}

const abortController = new AbortController();
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<"timeout">((resolve) => {
timeoutId = setTimeout(resolve, USER_PREFERENCES_BOOTSTRAP_TIMEOUT_MS, "timeout");
});
const hydratePromise = hydrateUserPreferencesLocalCache({
configClient: apiState.api.config,
signal: abortController.signal,
}).catch((error) => {
console.warn("Failed to bootstrap user preferences:", error);
return undefined;
});

const startup = Promise.race([hydratePromise, timeoutPromise]);
// User preference hydration must happen before RouterProvider reads launch behavior, but
// startup still needs a hard fallback so a slow backend cannot trap users on the boot screen.
void startup
.then((result) => {
if (result === "timeout") {
abortController.abort();
}
if (abortController.signal.aborted && result !== "timeout") {
return;
}

bootstrappedRef.current = true;
setReady(true);
})
.finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});

return () => {
abortController.abort();
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [apiState.api]);

if (bootstrappedRef.current || ready) {
return <>{props.children}</>;
}

if (apiState.status === "auth_required") {
return (
<AuthTokenModal
isOpen={true}
onSubmit={apiState.authenticate}
onSessionAuthenticated={apiState.retry}
error={apiState.error}
/>
);
}

if (apiState.status === "error") {
return <StartupConnectionError error={apiState.error} onRetry={apiState.retry} />;
}

return (
<LoadingScreen
statusText={
apiState.status === "reconnecting"
? `Reconnecting to backend (attempt ${apiState.attempt})...`
: "Loading preferences"
}
/>
);
}

interface AppLoaderProps {
/** Optional pre-created ORPC api?. If provided, skips internal connection setup. */
client?: APIClient;
Expand All @@ -41,15 +129,19 @@ export function AppLoader(props: AppLoaderProps) {
return (
<ThemeProvider>
<APIProvider client={props.client}>
<PolicyProvider>
<RouterProvider>
<ProjectProvider>
<WorkspaceProvider>
<AppLoaderInner />
</WorkspaceProvider>
</ProjectProvider>
</RouterProvider>
</PolicyProvider>
<UserPreferencesStartupGate>
<PolicyProvider>
<RouterProvider>
<ProjectProvider>
<WorkspaceProvider>
<UserPreferencesProvider>
<AppLoaderInner />
</UserPreferencesProvider>
</WorkspaceProvider>
</ProjectProvider>
</RouterProvider>
</PolicyProvider>
</UserPreferencesStartupGate>
</APIProvider>
</ThemeProvider>
);
Expand Down
8 changes: 8 additions & 0 deletions src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ function createProjectContextValue(
resolveProjectPath: () => null,
getProjectConfig: () => undefined,
loading: false,
loaded: true,
loadError: null,
refreshProjects: () => Promise.resolve(),
addProject: () => undefined,
removeProject: () => Promise.resolve({ success: true }),
Expand Down Expand Up @@ -852,6 +854,8 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => {
resolveProjectPath: () => null,
getProjectConfig: () => projectConfig,
loading: false,
loaded: true,
loadError: null,
refreshProjects: () => Promise.resolve(),
addProject: () => undefined,
removeProject: () => Promise.resolve({ success: true }),
Expand Down Expand Up @@ -958,6 +962,8 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => {
resolveProjectPath: () => null,
getProjectConfig: () => projectConfig,
loading: false,
loaded: true,
loadError: null,
refreshProjects: () => Promise.resolve(),
addProject: () => undefined,
removeProject: () => Promise.resolve({ success: true }),
Expand Down Expand Up @@ -1069,6 +1075,8 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => {
resolveProjectPath: () => null,
getProjectConfig: () => projectConfig,
loading: false,
loaded: true,
loadError: null,
refreshProjects: () => Promise.resolve(),
addProject: () => undefined,
removeProject: () => Promise.resolve({ success: true }),
Expand Down
3 changes: 2 additions & 1 deletion src/browser/components/ProjectSidebar/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
reorderProjects,
normalizeOrder,
} from "@/common/utils/projectOrdering";
import { PROJECT_ORDER_KEY } from "@/common/constants/storage";
import {
matchesKeybind,
formatKeybind,
Expand Down Expand Up @@ -1579,7 +1580,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
]);

// UI preference: project order persists in localStorage
const [projectOrder, setProjectOrder] = usePersistedState<string[]>("mux:projectOrder", []);
const [projectOrder, setProjectOrder] = usePersistedState<string[]>(PROJECT_ORDER_KEY, []);

// Build a stable signature of the project keys so effects don't fire on Map identity churn
const projectPathsSignature = React.useMemo(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ export const ShareMessagePopover: React.FC<ShareMessagePopoverProps> = ({
// Signing capabilities and enabled state
const [signingCapabilities, setSigningCapabilities] = useState<SigningCapabilities | null>(null);
const [signingCapabilitiesLoaded, setSigningCapabilitiesLoaded] = useState(false);
const [signingEnabled, setSigningEnabled] = usePersistedState(SHARE_SIGNING_KEY, true);
const [signingEnabled, setSigningEnabled] = usePersistedState(SHARE_SIGNING_KEY, true, {
listener: true,
});

// Load signing capabilities on first popover open
useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ export function ShareTranscriptDialog(props: ShareTranscriptDialogProps) {
// Signing capabilities and enabled state (matching per-message sharing)
const [signingCapabilities, setSigningCapabilities] = useState<SigningCapabilities | null>(null);
const [signingCapabilitiesLoaded, setSigningCapabilitiesLoaded] = useState(false);
const [signingEnabled, setSigningEnabled] = usePersistedState(SHARE_SIGNING_KEY, true);
const [signingEnabled, setSigningEnabled] = usePersistedState(SHARE_SIGNING_KEY, true, {
listener: true,
});
const [signed, setSigned] = useState(false);

const urlInputRef = useRef<HTMLInputElement>(null);
Expand Down
22 changes: 1 addition & 21 deletions src/browser/components/TerminalView/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { usePersistedState } from "@/browser/hooks/usePersistedState";
import {
DEFAULT_TERMINAL_FONT_CONFIG,
TERMINAL_FONT_CONFIG_KEY,
normalizeTerminalFontConfig,
type TerminalFontConfig,
} from "@/common/constants/storage";
import { useTerminalRouter } from "@/browser/terminal/TerminalRouterContext";
Expand All @@ -19,27 +20,6 @@ import {
} from "@/browser/terminal/terminalFontFamily";
import { TERMINAL_CONTAINER_ATTR } from "@/browser/utils/ui/keybinds";

function normalizeTerminalFontConfig(value: unknown): TerminalFontConfig {
if (!value || typeof value !== "object") {
return DEFAULT_TERMINAL_FONT_CONFIG;
}

const record = value as { fontFamily?: unknown; fontSize?: unknown };

const fontFamily =
typeof record.fontFamily === "string" && record.fontFamily.trim()
? record.fontFamily
: DEFAULT_TERMINAL_FONT_CONFIG.fontFamily;

const fontSizeNumber = Number(record.fontSize);
const fontSize =
Number.isFinite(fontSizeNumber) && fontSizeNumber > 0
? fontSizeNumber
: DEFAULT_TERMINAL_FONT_CONFIG.fontSize;

return { fontFamily, fontSize };
}

function canLoadFontFamily(primary: string, fontSize: number): boolean {
const family = stripOuterQuotes(primary).trim();
if (!family) {
Expand Down
6 changes: 4 additions & 2 deletions src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,15 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
// Notification on response toggle (workspace-level) - defaults to disabled
const [notifyOnResponse, setNotifyOnResponse] = usePersistedState<boolean>(
getNotifyOnResponseKey(workspaceId),
false
false,
{ listener: true }
);

// Auto-enable notifications for new workspaces (project-level)
const [autoEnableNotifications, setAutoEnableNotifications] = usePersistedState<boolean>(
getNotifyOnResponseAutoEnableKey(projectPath),
false
false,
{ listener: true }
);

// Popover state for notification settings (interactive on click)
Expand Down
13 changes: 13 additions & 0 deletions src/browser/contexts/ProjectContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,19 @@ describe("ProjectContext", () => {
expect(ctx().userProjects.has("/alpha")).toBe(false);
});

test("exposes project list load failures without marking projects as loaded", async () => {
createMockAPI({
list: () => Promise.reject(new Error("projects unavailable")),
});

const ctx = await setup();

await waitFor(() => expect(ctx().loading).toBe(false));
expect(ctx().loaded).toBe(false);
expect(ctx().loadError).toContain("projects unavailable");
expect(ctx().userProjects.size).toBe(0);
});

test("exposes intent-based project resolvers for user/system project lookups", async () => {
const systemProjectPath = "/path/to/system-project";
createMockAPI({
Expand Down
46 changes: 40 additions & 6 deletions src/browser/contexts/ProjectContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,12 @@ export interface ProjectContext {
resolveProjectPath: (query: ProjectQuery) => string | null;
/** Read project config from the full project map (includes system projects). */
getProjectConfig: (projectPath: string) => ProjectConfig | undefined;
/** True while initial project list is loading */
/** True while the initial project list request is in flight. */
loading: boolean;
/** True after at least one project list request completes successfully. */
loaded: boolean;
/** Last project list load failure, if the latest completed request failed. */
loadError: string | null;
refreshProjects: () => Promise<void>;
addProject: (normalizedPath: string, projectConfig: ProjectConfig) => void;
removeProject: (path: string, options?: { force?: boolean }) => Promise<ProjectRemoveResult>;
Expand Down Expand Up @@ -136,6 +140,8 @@ export function ProjectProvider(props: { children: ReactNode }) {
[allProjectsInternal]
);
const [loading, setLoading] = useState(true);
const [loaded, setLoaded] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [projectCreateInitialPath, setProjectCreateInitialPath] = useState<string | undefined>();
const [isProjectCreateModalOpen, setProjectCreateModalOpen] = useState(false);
const [workspaceModalState, setWorkspaceModalState] = useState<WorkspaceModalState>({
Expand All @@ -159,7 +165,11 @@ export function ProjectProvider(props: { children: ReactNode }) {
const latestAppliedProjectsRefreshSeqRef = useRef(0);

const refreshProjects = useCallback(async () => {
if (!api) return;
if (!api) {
setLoaded(false);
setLoadError("API not connected");
return;
}

const refreshSeq = projectsRefreshSeqRef.current + 1;
projectsRefreshSeqRef.current = refreshSeq;
Expand All @@ -174,22 +184,42 @@ export function ProjectProvider(props: { children: ReactNode }) {

latestAppliedProjectsRefreshSeqRef.current = refreshSeq;
setAllProjectsInternal(new Map(projectsList));
setLoaded(true);
setLoadError(null);
} catch (error) {
// Ignore out-of-date refreshes so an older error can't clobber a newer success.
if (refreshSeq < latestAppliedProjectsRefreshSeqRef.current) {
return;
}

// Keep the previous project list on error to avoid emptying the sidebar.
// Keep the previous project list on error so scoped user preferences are not pruned.
console.error("Failed to load projects:", error);
setLoadError(getErrorMessage(error));
}
}, [api]);

useEffect(() => {
void (async () => {
let cancelled = false;
setLoading(true);

const initialRefresh = async () => {
await refreshProjects();
setLoading(false);
})();
if (!cancelled) {
setLoading(false);
}
};

const refreshPromise = initialRefresh();
refreshPromise.catch((error) => {
if (!cancelled) {
setLoadError(getErrorMessage(error));
setLoading(false);
}
});

return () => {
cancelled = true;
};
}, [refreshProjects]);

const addProject = useCallback((normalizedPath: string, projectConfig: ProjectConfig) => {
Expand Down Expand Up @@ -502,6 +532,8 @@ export function ProjectProvider(props: { children: ReactNode }) {
resolveNewChatProjectPath,
getProjectConfig,
loading,
loaded,
loadError,
refreshProjects,
addProject,
removeProject,
Expand Down Expand Up @@ -533,6 +565,8 @@ export function ProjectProvider(props: { children: ReactNode }) {
resolveNewChatProjectPath,
getProjectConfig,
loading,
loaded,
loadError,
refreshProjects,
addProject,
removeProject,
Expand Down
Loading
Loading