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
49 changes: 30 additions & 19 deletions src/browser/components/ChatInput/CreationControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,10 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) {
const state = props.runtimeAvailabilityState;
const availabilityMap = state?.status === "loaded" ? state.data : null;

// Hide only during loading (prevents flash), show on failed (allows fallback selection)
// Hide devcontainer only when confirmed missing (not during loading - would cause layout flash)
const hideDevcontainer =
state?.status === "loading" ||
(availabilityMap?.devcontainer?.available === false &&
availabilityMap.devcontainer.reason === "No devcontainer.json found");
availabilityMap?.devcontainer?.available === false &&
availabilityMap.devcontainer.reason === "No devcontainer.json found";

const runtimeOptions = hideDevcontainer
? RUNTIME_OPTIONS.filter((option) => option.value !== RUNTIME_MODE.DEVCONTAINER)
Expand Down Expand Up @@ -254,6 +253,8 @@ export function CreationControls(props: CreationControlsProps) {
const availabilityMap =
runtimeAvailabilityState.status === "loaded" ? runtimeAvailabilityState.data : null;
const showTrunkBranchSelector = props.branches.length > 0 && runtimeMode !== RUNTIME_MODE.LOCAL;
// Show loading skeleton while branches are loading to avoid layout flash
const showBranchLoadingPlaceholder = !props.branchesLoaded && runtimeMode !== RUNTIME_MODE.LOCAL;

// Centralized devcontainer selection logic
const devcontainerSelection = resolveDevcontainerSelection({
Expand Down Expand Up @@ -493,25 +494,35 @@ export function CreationControls(props: CreationControlsProps) {
/>
</div>
)}

{/* SSH Host Input - hidden when Coder is enabled */}
{selectedRuntime.mode === "ssh" && !props.coderProps?.enabled && (
{/* Loading placeholder - reserves space while branches load to avoid layout flash */}
{showBranchLoadingPlaceholder && (
<div className="flex items-center gap-2">
<label className="text-muted-foreground text-xs">host</label>
<input
type="text"
value={selectedRuntime.host}
onChange={(e) => onSelectedRuntimeChange({ mode: "ssh", host: e.target.value })}
placeholder="user@host"
disabled={props.disabled}
className={cn(
"bg-bg-dark text-foreground border-border-medium focus:border-accent h-7 w-36 rounded-md border px-2 text-sm focus:outline-none disabled:opacity-50",
props.runtimeFieldError === "ssh" && "border-red-500"
)}
/>
<span className="text-muted-foreground text-xs">from</span>
<div className="bg-bg-dark/50 h-7 w-24 animate-pulse rounded-md" />
</div>
)}

{/* SSH Host Input - hidden when Coder is enabled or will be enabled after checking */}
{selectedRuntime.mode === "ssh" &&
!props.coderProps?.enabled &&
// Also hide when Coder is still checking but has saved config (will enable after check)
!(props.coderProps?.coderInfo === null && props.coderProps?.coderConfig) && (
<div className="flex items-center gap-2">
<label className="text-muted-foreground text-xs">host</label>
<input
type="text"
value={selectedRuntime.host}
onChange={(e) => onSelectedRuntimeChange({ mode: "ssh", host: e.target.value })}
placeholder="user@host"
disabled={props.disabled}
className={cn(
"bg-bg-dark text-foreground border-border-medium focus:border-accent h-7 w-36 rounded-md border px-2 text-sm focus:outline-none disabled:opacity-50",
props.runtimeFieldError === "ssh" && "border-red-500"
)}
/>
</div>
)}

{/* Runtime-specific config inputs */}

{selectedRuntime.mode === "docker" && (
Expand Down
12 changes: 10 additions & 2 deletions src/browser/components/ProjectMCPOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { MCPServerInfo } from "@/common/types/mcp";
import { useAPI } from "@/browser/contexts/API";
import { useSettings } from "@/browser/contexts/SettingsContext";
import { Button } from "@/browser/components/ui/button";
import { getMCPServersKey } from "@/common/constants/storage";
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";

interface ProjectMCPOverviewProps {
projectPath: string;
Expand All @@ -16,7 +18,10 @@ export const ProjectMCPOverview: React.FC<ProjectMCPOverviewProps> = (props) =>

const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [servers, setServers] = React.useState<Record<string, MCPServerInfo>>({});
// Initialize from localStorage cache to avoid flash
const [servers, setServers] = React.useState<Record<string, MCPServerInfo>>(() =>
readPersistedState<Record<string, MCPServerInfo>>(getMCPServersKey(projectPath), {})
);

React.useEffect(() => {
if (!api || settings.isOpen) return;
Expand All @@ -27,7 +32,10 @@ export const ProjectMCPOverview: React.FC<ProjectMCPOverviewProps> = (props) =>
.list({ projectPath })
.then((result) => {
if (cancelled) return;
setServers(result ?? {});
const newServers = result ?? {};
setServers(newServers);
// Cache for next load
updatePersistedState(getMCPServersKey(projectPath), newServers);
setError(null);
})
.catch((err) => {
Expand Down
33 changes: 27 additions & 6 deletions src/browser/components/ProjectPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@ import { ConfigureProvidersPrompt } from "./ConfigureProvidersPrompt";
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
import type { ProvidersConfigMap } from "@/common/orpc/types";
import { AgentsInitBanner } from "./AgentsInitBanner";
import { usePersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import {
usePersistedState,
updatePersistedState,
readPersistedState,
} from "@/browser/hooks/usePersistedState";
import {
getAgentIdKey,
getAgentsInitNudgeKey,
getArchivedWorkspacesKey,
getInputKey,
getPendingScopeId,
getProjectScopeId,
Expand Down Expand Up @@ -72,7 +77,10 @@ export const ProjectPage: React.FC<ProjectPageProps> = ({
const { api } = useAPI();
const chatInputRef = useRef<ChatInputAPI | null>(null);
const pendingAgentsInitSendRef = useRef(false);
const [archivedWorkspaces, setArchivedWorkspaces] = useState<FrontendWorkspaceMetadata[]>([]);
// Initialize from localStorage cache to avoid flash when archived workspaces appear
const [archivedWorkspaces, setArchivedWorkspaces] = useState<FrontendWorkspaceMetadata[]>(() =>
readPersistedState<FrontendWorkspaceMetadata[]>(getArchivedWorkspacesKey(projectPath), [])
);
const [showAgentsInitNudge, setShowAgentsInitNudge] = usePersistedState<boolean>(
getAgentsInitNudgeKey(projectPath),
false,
Expand Down Expand Up @@ -128,8 +136,13 @@ export const ProjectPage: React.FC<ProjectPageProps> = ({

const syncArchivedState = useCallback(() => {
const next = Array.from(archivedMapRef.current.values());
setArchivedWorkspaces((prev) => (archivedListsEqual(prev, next) ? prev : next));
}, []);
setArchivedWorkspaces((prev) => {
if (archivedListsEqual(prev, next)) return prev;
// Persist to localStorage for optimistic cache on next load
updatePersistedState(getArchivedWorkspacesKey(projectPath), next);
return next;
});
}, [projectPath]);

// Fetch archived workspaces for this project on mount
useEffect(() => {
Expand Down Expand Up @@ -289,8 +302,16 @@ export const ProjectPage: React.FC<ProjectPageProps> = ({
/>
)}
{/* Configured providers bar - compact icon carousel */}
{providersConfig && hasProviders && (
<ConfiguredProvidersBar providersConfig={providersConfig} />
{providersLoading ? (
// Skeleton placeholder matching ConfiguredProvidersBar height
<div className="flex items-center justify-center gap-2 py-1.5">
<div className="bg-bg-dark/50 h-7 w-32 animate-pulse rounded" />
</div>
) : (
hasProviders &&
providersConfig && (
<ConfiguredProvidersBar providersConfig={providersConfig} />
)
)}
{/* ChatInput for workspace creation - includes section selector */}
<ChatInput
Expand Down
3 changes: 2 additions & 1 deletion src/browser/hooks/useDraftWorkspaceSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,10 +300,11 @@ export function useDraftWorkspaceSettings(

// When the user switches into SSH/Docker/Devcontainer mode, seed the field with the remembered config.
// This avoids clearing the last values when the UI switches modes with an empty field.
// Skip on initial mount (prevMode === null) since useState initializer handles that case.
const prevSelectedRuntimeModeRef = useRef<RuntimeMode | null>(null);
useEffect(() => {
const prevMode = prevSelectedRuntimeModeRef.current;
if (prevMode !== selectedRuntime.mode) {
if (prevMode !== null && prevMode !== selectedRuntime.mode) {
if (selectedRuntime.mode === RUNTIME_MODE.SSH) {
const needsHostRestore = !selectedRuntime.host.trim() && lastSshHost.trim();
const needsCoderRestore =
Expand Down
18 changes: 18 additions & 0 deletions src/common/constants/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,24 @@ export function getMCPTestResultsKey(projectPath: string): string {
return `mcpTestResults:${projectPath}`;
}

/**
* Get the localStorage key for cached archived workspaces per project
* Format: "archivedWorkspaces:{projectPath}"
* Stores: Array of workspace metadata objects (optimistic cache)
*/
export function getArchivedWorkspacesKey(projectPath: string): string {
return `archivedWorkspaces:${projectPath}`;
}

/**
* Get the localStorage key for cached MCP servers per project
* Format: "mcpServers:{projectPath}"
* Stores: Record<serverName, MCPServerInfo> (optimistic cache)
*/
export function getMCPServersKey(projectPath: string): string {
return `mcpServers:${projectPath}`;
}

/**
* Get the localStorage key for thinking level preference per scope (workspace/project).
* Format: "thinkingLevel:{scopeId}"
Expand Down
Loading